简介:面向使用 DevExpress WinForm 的 .NET 开发者,这份资源是一套可复用的通用控件导出 Excel 解决方案,适合需要把表格、透视表、图表等控件内容完整导出到 Excel 的项目场景。它统一处理 GridControl、PivotGridControl 等控件的导出逻辑,弥补自带方法无法导出图片、多表头丢失的缺陷,并解决数据透视表自动分组带来的格式偏差;支持多个控件写入同一个 Excel 的不同工作表,真正做到所见即所得。压缩包共 179 个文件,约 35.09 MB,以 DLL 运行库、XML 配置和 C# 源码为主体,同时包含示例程序、工程配置文件及调试符号等,便于直接引用或二次开发。目前已有 1021 人浏览学习。借助源码、示例和配套文件,读者可以快速接入导出逻辑,省去逐个控件适配的时间,也能借鉴作者对 DevExpress 导出边界场景的处理思路,适合有复杂报表导出需求的中高级 WinForm 开发者。
1. 多控件导出Excel,为什么不能只写一个控件的小工具
接手WinForm报表类项目时,最常见到的需求不是“导出某张表”,而是“主界面里五六个表格,一键导出到一个Excel,每个控件一个Sheet”。一开始很多人会给GridControl写一个导出方法,给TreeList再写一个,给Chart再写第三个,结果每个方法都在重复处理文件流、列头、单元格格式,换一个报表页又要复制一遍。其实这件事的本质是把“控件的可见数据”变成“Excel里有格式的单元格”,和具体控件类型无关。这里整理了一套基于Dev控件库的通用导出类,支持多个控件按Sheet分包进同一个.xlsx,覆盖取数、列头样式、统计行、内存控制和常见翻车点,适合报表工具开发者和刚接触导出的程序员。
2. 通用控件导出的实现路径:从控件到DataTable再到Sheet
2.1 选型:为什么中间层选DataTable而不是直接写Excel
在WinForm项目里,导出Excel有三条常见路线,先说清楚为什么最终落在DataTable上。
第一条路线是直接用控件自带的视图导出,比如GridView对应的ExportToExcelFile。优点是不需要处理xlsx内部细节,连列宽、单元格样式都会自动生成;缺点是它的函数签名里写着“把当前视图导出为一个完整工作薄文件”,也就是说你拿到的每一个文件都包含一个Sheet。多个控件分开导出后,往往还要再写一个合并脚本来拼成单文件,麻烦且容易丢格式。更关键的是它通常弹出对话框或写到某个固定路径,和“一键批量导出”的目标冲突,无法在后台静默执行。
第二条路线是拿到控件以后,直接找出每个单元格的值往NPOI的Cell里填。这条路完全可以走,但做出来的方法基本是给某个控件定制的。比如GridControl和TreeList,一个用Row+Column,一个用Node,字段名相同但循环方式完全不一样;Chart又完全不是列表型控件。如果每个类型都写一套“读出再写入”,后续每多一种控件就要多改一次公共入口,维护成本很高。
第三条路线就是先做取数,再做导出,取数的结果统一落到DataTable。DataTable有自带的列名、行集合、类型信息,而且是.NET体系里最常用的缓冲容器,写导出时只需要面对一个DataTable,不用管它原来是什么控件。这样,导出层变化很小,新控件只影响取数层,这是做通用导出最划算的结构。
这个选择还有一个隐藏好处:DataTable可以做数据清洗,比如分页过滤、剔除隐藏列、提前把计算列的值生成好,统计行也可以在DataTable上先算好再写入Excel。后面几节的取数方法都按这个思路设计。
2.2 三种控件取数:GridView、TreeList、Chart
先从GridControl开始。重点是把界面当前可见的列和行取出来,不要取隐藏列,也不要取数据源里但没挂在界面上的列。
private static DataTable GridToDataTable(GridControl grid) { if (grid.MainView is not GridView view) return null; var dt = new DataTable(); foreach (GridColumn col in view.VisibleColumns) { if (string.IsNullOrEmpty(col.FieldName)) continue; dt.Columns.Add(col.FieldName, typeof(object)); } for (int r = 0; r < view.DataRowCount; r++) { var row = dt.NewRow(); foreach (GridColumn col in view.VisibleColumns) { if (string.IsNullOrEmpty(col.FieldName)) continue; row[col.FieldName] = view.GetRowCellValue(r, col); } dt.Rows.Add(row); } return dt; }参数说明:view.VisibleColumns得到的是界面上可见的列,隐藏列会自动剔除;view.DataRowCount是数据源的总行数,如果当前视图应用了筛选器,需要改用view.RowCount,否则导出结果和界面不一致。GetRowCellValue拿的是物理值,如果列上设置了DisplayFormat,这里拿到的仍是原始数据,格式化放到写入阶段处理,这样Excel里既能显示原始数,也能按业务需求再改格式。
接着是TreeList。TreeList不是简单表格,它有父子层级,AllNodes只返回根节点,必须递归才能拿到所有子节点。
private static DataTable TreeToDataTable(TreeList tree) { var dt = new DataTable(); foreach (TreeListColumn col in tree.Columns) { if (string.IsNullOrEmpty(col.FieldName)) continue; dt.Columns.Add(col.FieldName, typeof(object)); } void AppendNode(TreeListNode node) { var row = dt.NewRow(); foreach (TreeListColumn col in tree.Columns) { if (string.IsNullOrEmpty(col.FieldName)) continue; row[col.FieldName] = node.GetValue(col.FieldName); } dt.Rows.Add(row); foreach (TreeListNode child in node.Nodes) { AppendNode(child); } } foreach (TreeListNode root in tree.Nodes) { AppendNode(root); } return dt; }这里用了一个本地函数AppendNode做深度递归,每一行先写当前节点的所有列,再往下钻子节点。如果有业务需要,还可以在取数时增加一列“层级”,把当前节点的层级数字填进去,方便Excel里做排序或分组。TreeList的字段名经常和Grid共用,所以后续写入逻辑可以复用。
Chart控件不是行列表格,它是一组序列点。导出时固定成三列更合理:系列名、参数值、数值。
private static DataTable ChartToDataTable(ChartControl chart) { var dt = new DataTable(); dt.Columns.Add("Series", typeof(string)); dt.Columns.Add("Argument", typeof(object)); dt.Columns.Add("Value", typeof(double)); foreach (Series s in chart.Series) { foreach (SeriesPoint p in s.Points) { double value = 0; if (p.Values != null && p.Values.Length > 0) { value = Convert.ToDouble(p.Values[0]); } dt.Rows.Add(s.Name, p.Argument, value); } } return dt; }p.Values是一个数组,因为有些图表一个点有多个Y值,这里取第一个作为主值。如果图表是现金流水趋势图,则p.Argument往往是DateTime,写入Excel时需要走日期格式化分支,在后面的样式章节统一处理。
为了让主导出方法不知道具体控件类型,可以做一个分发入口:
public static DataTable ControlToDataTable(Control c) { switch (c) { case GridControl grid: return GridToDataTable(grid); case TreeList tree: return TreeToDataTable(tree); case ChartControl chart: return ChartToDataTable(chart); default: throw new NotSupportedException($"不支持的控件类型: {c.GetType().Name}"); } }这个分发逻辑把控件类型隔离在入口,新增控件类型时,只需要加一个case和对应取数方法,导出层完全不用动。
2.3 写入工作薄的通用方法:NPOI的XSSFWorkbook
取数完成后,下一步是把DataTable写成一个Sheet。这里用NPOI作为写入工具,输出xlsx格式。
using NPOI.SS.UserModel; using NPOI.XSSF.UserModel; public static void WriteDataTable(ISheet sheet, DataTable dt) { var headerRow = sheet.CreateRow(0); for (int i = 0; i < dt.Columns.Count; i++) { headerRow.CreateCell(i).SetCellValue(dt.Columns[i].ColumnName); } for (int r = 0; r < dt.Rows.Count; r++) { var row = sheet.CreateRow(r + 1); for (int c = 0; c < dt.Columns.Count; c++) { var raw = dt.Rows[r][c]; if (raw == null || raw == DBNull.Value) continue; var cell = row.CreateCell(c); if (raw is int || raw is long || raw is float || raw is double || raw is decimal) { cell.SetCellValue(Convert.ToDouble(raw)); } else { cell.SetCellValue(raw.ToString()); } } } }这里的关键是类型判断。Excel脚本里如果全是文本,用户打开后没法直接求和、过滤、分组;所以基础数值类型提前转成double,URL和字符串保持文本。DateTime也暂时转字符串,下一章再引入真正的日期格式。
主导出方法把多个控件按Sheet分包,一次写出完整文件:
public static void Export(List<ExportSource> sources, string filePath) { using var fs = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); using var wb = new XSSFWorkbook(); foreach (var source in sources) { var dt = ControlToDataTable(source.Control); var sheet = wb.CreateSheet(source.SheetName); WriteDataTable(sheet, dt); dt.Dispose(); } wb.Write(fs); }ExportSource是一个简单的绑定对象,后面章节会给出完整定义。这里用using var wb,导出结束后自动释放。值得注意的坑是:wb.Write(fs)必须放在所有Sheet写完以后调用,放在循环里会导致Excel文件只保留最后一个Sheet的内容,甚至整个文件损坏。
3. 把控件数据映射成Excel样式:列头、宽度、统计行与类型
3.1 自动生成报表列头与列宽
GridControl的GridColumn有两个重要属性:FieldName是数据源字段名,Caption是界面列头显示文字。导出时应当用Caption作为Excel表头,否则用户看到FieldName会很难读懂。
可以在取数阶段把列头和字段名分开保存,DataTable本身不关心展示名,所以用一个小结构传递更稳妥:
public class ExportColumn { public string FieldName { get; set; } public string HeaderText { get; set; } }在GridToDataTable里,把列头信息装进这个列表,写入时再映射。树形控件同理,TreeListColumn也有Caption,可以直接用。
列宽不能直接依赖NPOI的sheet.AutoSizeColumn。它在中文场景下估算偏大,而且数据行多时会卡住界面。常见做法是扫描前200行,按字符串长度估算宽度:
private static void SetAutoColumnWidth(ISheet sheet, DataTable dt) { for (int c = 0; c < dt.Columns.Count; c++) { int maxWidth = dt.Columns[c].ColumnName.Length * 2; for (int r = 0; r < Math.Min(dt.Rows.Count, 200); r++) { var val = dt.Rows[r][c]; if (val is string s) { int len = System.Text.Encoding.Default.GetByteCount(s); maxWidth = Math.Max(maxWidth, len + 2); } } int width = Math.Min(maxWidth * 256, 80 * 256); sheet.SetColumnWidth(c, width); } }参数说明:Encoding.Default.GetByteCount(s)可以粗略把中文按双字节计算,英文按单字节计算,比简单用s.Length更接近实际像素。最后用80字符做上限,避免某一列非常长时把整个Excel视图撑爆。NPOI中列宽单位是1/256字符宽度,所以maxWidth * 256是通用换算方式。
3.2 统计行与合计行:改用Excel公式而不是拼字符串
很多报表导出需求要求在末尾有合计行。有些项目会把“总计:1000元”直接写成字符串塞进单元格,这样Excel里没法继续运算,用户拿到后还得手工改。正确做法是写Excel公式,让Excel自己计算。
private static void AppendSummaryRow(ISheet sheet, int startRow, int endRow, int columnIndex, string summaryType) { string colLetter = new NPOI.SS.Util.CellReference(0, columnIndex).FormatAsString(); string range = colLetter.Replace("$", "") + (startRow + 1) + ":" + colLetter.Replace("$", "") + (endRow + 1); string formula = summaryType switch { "Sum" => $"SUM({range})", "Avg" => $"AVERAGE({range})", "Count" => $"COUNT({range})", _ => $"SUM({range})" }; var row = sheet.CreateRow(endRow + 2); var cell = row.CreateCell(columnIndex); cell.SetCellFormula(formula); }参数说明:colLetter.Replace("$", "")是为了从NPOI的引用对象里去掉绝对引用符号,得到干净的列号。startRow + 1是因为Sheet里第一行是表头,数据从第二行开始。endRow + 2表示合计行与数据末尾空一行,视觉上更清晰。注意,如果数据行为空,直接写公式会让Excel显示错误值,所以导出前要先判断dt.Rows.Count > 0。
如果要从Dev控件自带的SummaryItem中拿到列汇总类型,可以这样映射:
foreach (GridColumn col in view.VisibleColumns) { if (col.SummaryItem == null) continue; string type = col.SummaryItem.SummaryType.ToString(); AppendSummaryRow(sheet, 1, sheet.LastRowNum, i, type); }这个方法比直接读取SummaryItem.DisplayFormat更可靠,因为DisplayFormat里包含“#,##0.00”这样的格式串,直接拼接会变成奇怪的文本。
3.3 单元格格式:日期、数字、百分比
NPOI中每个单元格格式都依赖一个ICellStyle,而ICellStyle和当前Workbook是绑定的。最稳的写法是用一个静态样式提供类,按Workbook实例做缓存:
public static class ExcelStyleProvider { private static readonly Dictionary<string, ICellStyle> Styles = new(); public static ICellStyle GetStyle(IWorkbook wb, string format) { if (!Styles.TryGetValue(format, out var style)) { style = wb.CreateCellStyle(); style.DataFormat = wb.CreateDataFormat().GetFormat(format); Styles[format] = style; } return style; } public static void Clear() { Styles.Clear(); } }注意:这个字典只缓存format字符串,如果同时打开多个Workbook,不同Workbook的Style不能混用。所以导出一开始要调用ExcelStyleProvider.Clear(),或者把字典换成Dictionary<Workbook, Dictionary<string, ICellStyle>>,这里为了简洁,约定每次导出前清空。
写入单元格时按实际类型分配格式:
private static void WriteCellValue(ICell cell, object value, IWorkbook wb) { if (value == null || value == DBNull.Value) return; if (value is DateTime dt) { cell.SetCellValue(dt); cell.CellStyle = ExcelStyleProvider.GetStyle(wb, "yyyy-MM-dd HH:mm:ss"); } else if (value is bool b) { cell.SetCellValue(b); } else if (value is int || value is long || value is float || value is double || value is decimal) { cell.SetCellValue(Convert.ToDouble(value)); cell.CellStyle = ExcelStyleProvider.GetStyle(wb, "#,##0.00"); } else { cell.SetCellValue(value.ToString()); } }这里有个容易踩的细节:bool不能放进IConvertible分支,否则它会被Convert.ToDouble(true)转成1,Excel里会出现数字1而不是TRUE/FALSE。字符串也不能走数值分支,因为“0001”这种文本会被转成数值1,丢失前导零。
百分比格式可以复用同一个Provider,用0.00%作为format。日期格式用yyyy-MM-dd HH:mm:ss,这样时间戳导出后还能在Excel里做时间过滤。
4. 多控件分工作薄的调度:Sheet命名、顺序与内存控制
4.1 用ExportSource对象管理每个Sheet的导出参数
前文用到的ExportSource是承载导出参数的核心对象,把它定义得完整一点,可以避免后期在主方法里堆一大堆可选参数:
public class ExportSource { public Control Control { get; set; } public string SheetName { get; set; } public bool IncludeSummary { get; set; } public bool AutoColumnWidth { get; set; } = true; public DataTable OverrideData { get; set; } public ExportSource(Control control, string sheetName) { Control = control; SheetName = sheetName; } }调用时按界面顺序加入列表,导出方法按列表顺序创建Sheet,这样Excel里的Sheet顺序就能和界面呼应。如果不希望某个控件参与导出,直接在列表里不放进Control,而不是设置隐藏标记,逻辑更直观。
主导出方法升级为接受List<ExportSource>:
public static void Export(List<ExportSource> sources, string filePath) { using var fs = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); using var wb = new XSSFWorkbook(); foreach (var src in sources) { var dt = src.OverrideData ?? ControlToDataTable(src.Control); var sheet = wb.CreateSheet(ValidateSheetName(src.SheetName)); WriteDataTable(sheet, dt); if (src.IncludeSummary && src.Control is GridControl grid) { AppendSummaryFromGrid(sheet, grid, dt); } if (src.AutoColumnWidth) { SetAutoColumnWidth(sheet, dt); } dt.Dispose(); } wb.Write(fs); }OverrideData是一个逃逸口:如果某个报表页不需要真从界面取数,而是后端直接算好了一个DataTable,就可以把它塞进这个字段,绕过控件取数逻辑。这在做权限过滤、行级权限时特别有用。
4.2 大数据量分批写入与内存控制
WinForm里一次导出的行数常常能到十几万行,如果先把所有控件的DataTable都塞进内存再写文件,内存很快就爆了。经验规则是:一个控件取完数,写入Sheet后立刻释放DataTable,不要等所有控件都转换完再写。
上面的Export方法已经做到了这点。但要进一步控制内存,还需要注意NPOI自身的加载方式。XSSFWorkbook在写入过程中会把每个Sheet的XML结构放在内存里,如果一次性创建大量Sheet,即使数据量不大,内存也会涨。如果源头是10万行以上,建议做分页读取:每次从控件取一页数据写入一个临时Sheet,最后再合并。不过大多数WinForm内部报表场景不会超过Excel的单表上限。
这里提供一个更稳的写法:在写入数据时,尽量避开sheet.AutoSizeColumn,因为它会遍历所有行做计算。前文已经用前200行的估算替代了它。另一个技巧是给文件流设置缓冲区,并用异步方式写入,减少UI线程卡顿感:
using var fs = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 8192, useAsync: true);4.3 多临时文件合并的替代方案
有些项目已经用控件自带导出方法生成了多个单Sheet的xlsx文件,不太想在取数层做大改动。这时可以用NPOI把多个临时文件合并成一个工作薄。
public static void MergeExcelFiles(string[] filePaths, string outputPath) { using var targetFs = new FileStream(outputPath, FileMode.Create, FileAccess.ReadWrite); using var targetWb = new XSSFWorkbook(); foreach (var path in filePaths) { using var srcFs = new FileStream(path, FileMode.Open, FileAccess.Read); using var srcWb = new XSSFWorkbook(srcFs); foreach (ISheet srcSheet in srcWb) { ISheet targetSheet = targetWb.CreateSheet(srcSheet.SheetName); for (int r = 0; r <= srcSheet.LastRowNum; r++) { var srcRow = srcSheet.GetRow(r); if (srcRow == null) continue; var targetRow = targetSheet.CreateRow(r); for (int c = 0; c < srcRow.LastCellNum; c++) { var srcCell = srcRow.GetCell(c); if (srcCell == null) continue; targetRow.CreateCell(c).SetCellValue(srcCell.ToString()); } } } } targetWb.Write(targetFs); }这个方法只搬了单元格值,样式、列宽、合并单元格全部丢失。所以它只适合临时拼接,不适合作为正式的通用导出方案。如果已有的控件导出方法本身带着样式且这些样式必须保留,建议走OpOpenXML的底层文档复制,而不是NPOI的逐格复制。这也是为什么前文反复强调绕开自带导出、统一走DataTable的原因——合并的坑比取数多得多。
5. 常见问题与排查:五个导出场景下的翻车点
5.1 文件扩展名无效,Excel打不开
现象:导出完成后,双击文件提示“Excel无法打开文件,因为文件格式或文件扩展名无效”。
原因:常见有两种。一种是用xls的扩展名保存XSSFWorkbook输出的内容,NPOI的XSSFWorkbook写的是OpenXML格式,必须用.xlsx后缀;另一种是导出文件还在被上一个FileStream占用,用户立即双击打开,Excel认为文件不完整。
解决:导出方法开头先用扩展名约束格式:
if (!filePath.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase)) { filePath += ".xlsx"; }同时,在写入前检查目标文件是否被占用:
try { using var probe = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (IOException) { // 文件被Excel或其他进程占用,提示用户关闭后重试 }确保整个Export方法结束后再关闭FileStream,外部不要提前释放流。导出完成后用File.GetLastWriteTime再次确认文件大小大于0,也是一个低成本的自检手段。
5.2 Sheet名称非法导致导出中断
现象:导出到第3个Sheet时抛异常,提示Invalid sheet name,前面的Sheet都没写入文件。
原因:用户给的Sheet名里包含\ / ? * [ ] :中的任意一个,NPOI都不允许。超过31个字符同样会报错。
解决:创建Sheet之前做清洗:
private static string ValidateSheetName(string name) { if (string.IsNullOrWhiteSpace(name)) name = "Sheet" + Guid.NewGuid().ToString("N").Substring(0, 6); foreach (var ch in new[] { '\\', '/', '?', '*', '[', ']', ':' }) { name = name.Replace(ch, '_'); } if (name.Length > 31) { name = name.Substring(0, 31); } return name; }这个清洗函数要在所有Sheet创建处统一调用,不要只在某个控件上临时判断。否则同一个方法在不同入口被调用时,行为不一致。顺便注意,Sheet名也不能为空字符串,如果业务侧传了空值,用随机占位名兜底。
5.3 导出后只有最后一个Sheet有数据
现象:多控件导出的xlsx打开后,每个Sheet都有,但前面几个Sheet是空白的,只剩最后一个Sheet有数据。
原因:写代码时把wb.Write(fs)放在了控件循环里。第一次循环结束就写了一次文件流,第二次循环虽然又往Workbook里加了新Sheet,但文件流已经定位到末尾或者被覆写,导致前面Sheet的内容被截断。另一个可能原因是循环里反复创建XSSFWorkbook,每个临时Workbook只写了一个Sheet,最后只保存了最后一个对象。
解决:核心是只在一个地方写文件,也就是所有Sheet都写完后再wb.Write(fs)。排查时先看导出方法里wb.Write的调用次数,大于1就是问题。重构后的标准写法在4.1已经给出,如果目录结构复杂,可以在循环里使用局部方法BuildSheet,但绝对不能在局部方法里写文件。
5.4 统计行列号错位,公式引用了空行
现象:合计行出现SUM(1:1)或A2:A9,但A9是表头,或者统计行的列顺序和表头错位。
原因:很多人在统计的时候用sheet.LastRowNum作为最后一行,但如果前面有空行或隐藏行,这个值就不准确。另一个原因是对应列的时候用i遍历DataTable列,而GridView的VisibleColumns顺序与DataTable列顺序不完全一致,特别是有列被隐藏时。
解决:在调用统计行之前,先确定数据行的真实区间:
int startDataRow = 1; int endDataRow = sheet.LastRowNum;同时,在生成DataTable时记录一个List<int>映射列索引,而不是直接按GridView列顺序写。最稳妥的排查方式是先导出一个小数据量的样例,用OpenXML工具或Excel逐个看公式引用的行列,和表头对照一次。统计列的编号最好用函数从DataTable.Columns中反查,不要依赖外部传入的GridView列序号。
5.5 中文列头变成乱码或显示为问号
现象:导出后打开xlsx,中文表头显示成????或乱码,但数据内容正常。
原因:NPOI写xlsx时内部使用UTF-8,一般不会乱码。出现乱码多半是用户先导出成了CSV,然后用Excel双击打开CSV,而CSV没有BOM导致中文识别错误。如果确认是xlsx格式出错,更多是因为在取数阶段把字符串做了Encoding.Default.GetBytes再转回new string,编码被转换了两次。
解决:取数阶段不要做任何编码转换,字符串原样放入DataTable。如果是CSV场景,用UTF-8带BOM写入:
using var writer = new StreamWriter(filePath, false, new System.Text.UTF8Encoding(true));另外,列头优先取Caption,而不是FieldName。如果FieldName本身是拼音或驼峰英文,用户看起来就像乱码,虽然不是真正的编码问题。
6. 进阶技巧:把导出改成可取消的异步任务,并留下完整日志
导出不是每次都能一次成功,尤其是在数据量大、控件多的时候。常见的做法是做成异步任务,用进度条显示“正在导出第几个Sheet”,同时允许用户在卡住时取消。下面这个扩展方法可以直接接在通用导出类上:
public static async Task ExportAsync( List<ExportSource> sources, string filePath, IProgress<string> progress, CancellationToken ct) { await Task.Run(() => { using var fs = new FileStream(filePath, FileMode.Create, FileAccess.ReadWrite); using var wb = new XSSFWorkbook(); for (int i = 0; i < sources.Count; i++) { ct.ThrowIfCancellationRequested(); var src = sources[i]; progress?.Report($"正在导出 {src.SheetName} ({i + 1}/{sources.Count})"); var dt = src.OverrideData ?? ControlToDataTable(src.Control); var sheet = wb.CreateSheet(ValidateSheetName(src.SheetName)); WriteDataTable(sheet, dt); if (src.IncludeSummary && src.Control is GridControl grid) { AppendSummaryFromGrid(sheet, grid, dt); } if (src.AutoColumnWidth) { SetAutoColumnWidth(sheet, dt); } dt.Dispose(); } wb.Write(fs); }, ct); }在WinForm里调用:
var progress = new Progress<string>(msg => statusLabel.Text = msg); var cts = new CancellationTokenSource(); try { await ExportAsync(sources, "报表.xlsx", progress, cts.Token); } catch (OperationCanceledException) { MessageBox.Show("导出已取消"); } catch (Exception ex) { File.WriteAllText("export_log.txt", $"{DateTime.Now}: {ex}"); MessageBox.Show("导出失败,已写入日志"); }日志里至少要记录异常类型、堆栈和当前导出到的Sheet名。更细一点,可以在每个Sheet写入成功后追加一行日志:
progress?.Report($"{src.SheetName} 完成,共 {dt.Rows.Count} 行");这种写法比一个大事务维护起来舒服得多。取消功能的关键是把CancellationToken传入Task.Run,这样用户点取消后,循环里的ThrowIfCancellationRequested能立即抛异常,文件流会在using作用域里正常关闭,不会留下半个文件。从那以后,我每次设计导出功能都会先问一句:这个界面会不会有多个Sheet?如果有,就直接套用这套通用类,而不是先写一个单控件版本再去改造成多Sheet版本。希望帮到你。
本文还有配套的精品资源,点击获取