ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WinForm SQL真分页控件:OFFSET/FETCH+UI状态同步实现

WinForm SQL真分页控件:OFFSET/FETCH+UI状态同步实现 简介这是一份面向Windows Forms初学者与中级开发者的实用分页控件实战资源聚焦解决大数据量下DataGridView性能瓶颈问题提供开箱即用的SQL数据库集成方案。资源包含65个文件主体为20个C#源码文件含PagerControl.cs、Form1.cs等核心控件与窗体逻辑、8个.resx本地化资源、2个.sln/.csproj工程文件、以及编译输出所需的.dll、.exe和调试符号.pdb整体压缩包仅218KB轻量易集成。已有836人学习下载说明其在Winform桌面应用开发中具备较强实操参考价值。读者可直接复用分页控件源码快速构建支持页码跳转、每页条数设置、SQL Server数据绑定及OFFSET/FETCH分页查询的完整界面模块同时通过工程结构与多窗体设计StartForm、Form1、Form2掌握Winform项目组织规范与UI分层实践。1. WinForm 分页控件不是“加个控件就完事”它本质是数据库查询UI状态用户直觉的三角平衡你写了个 WinForm 程序DataGridView 绑定了一张有 50 万条订单记录的 SQL Server 表双击运行——界面卡死 8 秒滚动条拖不动点下一页直接弹窗报错“内存溢出”。这时候才意识到WinForm 自带的 BindingNavigator 是个摆设DataTable.Load()是把整张表全拉进内存再分页根本不是真分页。所谓“很好用的分页控件”核心不在 UI 多漂亮而在于它能否把SELECT * FROM Orders这种危险语句自动翻译成带OFFSET 200 ROWS FETCH NEXT 100 ROWS ONLY的高效查询同时让翻页按钮、页码输入框、总页数显示、当前页高亮这些 UI 元素和后端 SQL 的Skip、Take、TotalCount三个参数严丝合缝地咬合。它服务的对象不是数据库管理员而是业务员——他只关心“第 37 页的客户电话有没有错”不关心你用了ROW_NUMBER() OVER()还是OFFSET/FETCH。所以本文不讲抽象原理只讲怎么用一个轻量、无第三方依赖、可嵌入任何现有 WinForm 项目的分页控件把 SQL 分页从“玄学调参”变成“改两行代码就能上线”的确定性动作。适合正在维护老系统、没精力重构成 WPF/.NET MAUI、又不想让用户等得骂娘的 C# 工程师。2. 为什么不用 BindingSource DataTable——从三类典型翻车场景反推分页控件设计边界很多工程师第一反应是“BindingSource 不就是干这个的”——这恰恰是踩坑起点。我们拆解三个真实项目中反复出现的失败模式反向定义一个“真正好用”的分页控件必须守住的底线。2.1 场景一数据量暴增导致 UI 假死典型表现滚动条拖拽延迟 1.5 秒BindingSource 默认绑定 DataTable 后所有分页操作都在内存里做。假设你查了 10 万行每行 20 字段平均 500 字节/行 → 内存占用 ≈ 50MB。WinForm 主线程要频繁重绘 DataGridView还要响应鼠标滚轮、键盘方向键、点击单元格等事件CPU 占用飙到 95%UI 线程被锁死。这不是性能优化能解决的是架构级错误。提示BindingSource 的Position属性只是切换 DataTable 的当前行索引它不触发新 SQL 查询。你点“下一页”它只是把Position 100但那 100 行数据早就在内存里了——问题是你根本不需要加载全部 10 万行。2.2 场景二实时数据不同步典型表现“刚新增的订单在第 1 页看不到刷新后跳到第 3 页”BindingSource DataTable 模式下数据是一次性快照。用户 A 在第 1 页操作时用户 B 插入了 200 条新订单。当用户 A 点“刷新”BindingSource 重新Fill()整张表10 万行变 10.02 万行原第 1 页的 100 行现在可能成了第 2 页的一部分。更糟的是如果用户 A 正在编辑某行Fill()会清空整个 DataTable未保存的修改直接丢失。2.3 场景三复杂查询无法适配典型表现“WHERE 条件一加分页总数算不准跳页错乱”真实业务查询往往带多表 JOIN、子查询、聚合函数。例如统计每个客户的订单总额并分页SELECT c.Name, SUM(o.Amount) AS Total FROM Customers c JOIN Orders o ON c.Id o.CustomerId GROUP BY c.Id, c.NameBindingSource 对这种结果集完全无感——它不知道SUM()是聚合也不知道GROUP BY导致行数锐减。你用COUNT(*)算总数错必须用COUNT(DISTINCT c.Id)才对。而 BindingSource 不提供钩子让你插手总数计算逻辑。结论一个“很好用”的分页控件必须满足三个硬性条件①查询分离UI 分页操作必须触发新 SQL 查询且只取当前页所需数据SELECT ... OFFSET Skip ROWS FETCH NEXT Take ROWS ONLY②总数解耦总记录数必须通过独立的SELECT COUNT(*) ...或优化后的COUNT(1)查询获取且支持自定义 COUNT 语句应对 GROUP BY 场景③状态隔离每页数据加载互不影响支持异步加载避免 UI 阻塞且允许用户在加载中取消请求。3. 手把手实现一个可复用的分页控件从 UserControl 到 SQL 查询引擎我们不引入第三方库如 DevComponents、DevExpress也不用 NuGet 包纯 C# .NET Framework 4.7.2 实现。目标是拖一个控件到 Form 上设置几个属性写两行代码就能跑通 SQL 分页。3.1 控件结构设计三层职责清晰划分层级组件职责是否可替换UI 层PagingControl : UserControl提供页码按钮、跳转输入框、总页数标签、每页条数下拉框✅ 可完全自定义外观协调层PagingManager内部类管理当前页、每页数、总页数、是否正在加载等状态暴露LoadPage(int page)方法❌ 核心逻辑不建议改数据层IDataProviderT接口定义Task(ListT, int) GetPageAsync(int skip, int take)和Taskint GetTotalCountAsync()两个方法✅ 必须由你实现适配你的 SQL注意IDataProviderT是关键抽象。它把“怎么连数据库”“怎么写 SQL”“怎么映射实体”全部交给你控件只管分页逻辑。这样既轻量又不绑架你的数据访问技术栈Dapper / EF Core / 原生 ADO.NET 都行。3.2 创建 PagingControl UserControl含完整 UI 代码新建 WinForm 项目 → 右键“解决方案资源管理器” → “添加” → “用户控件”命名为PagingControl.cs。粘贴以下代码已精简 UI 样式聚焦功能// PagingControl.cs using System; using System.Drawing; using System.Windows.Forms; public partial class PagingControl : UserControl { private readonly Panel _panel new Panel(); private readonly Label _lblTotal new Label(); private readonly Label _lblCurrent new Label(); private readonly NumericUpDown _nudPage new NumericUpDown(); private readonly ComboBox _cmbPageSize new ComboBox(); private readonly Button _btnFirst new Button(); private readonly Button _btnPrev new Button(); private readonly Button _btnNext new Button(); private readonly Button _btnLast new Button(); private readonly Button _btnGo new Button(); public PagingControl() { InitializeComponent(); SetupUI(); WireEvents(); } private void InitializeComponent() { this.Size new Size(600, 40); this.Padding new Padding(5); this.BackColor Color.White; } private void SetupUI() { // 总记录数标签 _lblTotal.Text 共 0 条; _lblTotal.AutoSize true; _lblTotal.Font new Font(微软雅黑, 9F); // 当前页标签如“第 1 页” _lblCurrent.Text 第 1 页; _lblCurrent.AutoSize true; _lblCurrent.Font new Font(微软雅黑, 9F); // 页码输入框 _nudPage.Minimum 1; _nudPage.Maximum 99999; _nudPage.Width 60; _nudPage.Font new Font(微软雅黑, 9F); // 每页条数下拉框 _cmbPageSize.Items.AddRange(new string[] { 10, 20, 50, 100 }); _cmbPageSize.SelectedIndex 0; _cmbPageSize.Width 60; _cmbPageSize.Font new Font(微软雅黑, 9F); // 按钮文本 _btnFirst.Text |; _btnPrev.Text ; _btnNext.Text ; _btnLast.Text |; _btnGo.Text 跳转; // 布局左对齐总条数中间页码区右对齐跳转区 _panel.Controls.Add(_lblTotal); _panel.Controls.Add(_lblCurrent); _panel.Controls.Add(_nudPage); _panel.Controls.Add(new Label { Text 页, AutoSize true, Font new Font(微软雅黑, 9F) }); _panel.Controls.Add(_cmbPageSize); _panel.Controls.Add(new Label { Text 条/页, AutoSize true, Font new Font(微软雅黑, 9F) }); _panel.Controls.Add(_btnFirst); _panel.Controls.Add(_btnPrev); _panel.Controls.Add(_btnNext); _panel.Controls.Add(_btnLast); _panel.Controls.Add(_btnGo); // 使用 TableLayoutPanel 更易布局此处简化为手动定位 var y 5; _lblTotal.Location new Point(5, y); _lblCurrent.Location new Point(_lblTotal.Right 10, y); _nudPage.Location new Point(_lblCurrent.Right 10, y); var labelPage new Label { Text 页, AutoSize true, Font new Font(微软雅黑, 9F) }; labelPage.Location new Point(_nudPage.Right 5, y); _panel.Controls.Add(labelPage); _cmbPageSize.Location new Point(labelPage.Right 10, y); var labelPerPage new Label { Text 条/页, AutoSize true, Font new Font(微软雅黑, 9F) }; labelPerPage.Location new Point(_cmbPageSize.Right 5, y); _panel.Controls.Add(labelPerPage); _btnFirst.Location new Point(labelPerPage.Right 10, y); _btnPrev.Location new Point(_btnFirst.Right 5, y); _btnNext.Location new Point(_btnPrev.Right 5, y); _btnLast.Location new Point(_btnNext.Right 5, y); _btnGo.Location new Point(_btnLast.Right 10, y); this.Controls.Add(_panel); } private void WireEvents() { _btnFirst.Click (s, e) OnPageChanged?.Invoke(this, new PageChangedEventArgs(1)); _btnPrev.Click (s, e) OnPageChanged?.Invoke(this, new PageChangedEventArgs(_nudPage.Value 1 ? (int)_nudPage.Value - 1 : 1)); _btnNext.Click (s, e) OnPageChanged?.Invoke(this, new PageChangedEventArgs((int)_nudPage.Value 1)); _btnLast.Click (s, e) OnPageChanged?.Invoke(this, new PageChangedEventArgs(int.MaxValue)); // 由外部计算总页数后修正 _btnGo.Click (s, e) OnPageChanged?.Invoke(this, new PageChangedEventArgs((int)_nudPage.Value)); _cmbPageSize.SelectedIndexChanged (s, e) OnPageSizeChanged?.Invoke(this, new PageSizeChangedEventArgs(Convert.ToInt32(_cmbPageSize.SelectedItem))); } // 事件页码变更、每页条数变更 public event EventHandlerPageChangedEventArgs OnPageChanged; public event EventHandlerPageSizeChangedEventArgs OnPageSizeChanged; // 公共方法更新 UI 状态 public void UpdateState(int currentPage, int totalPages, int totalCount, int pageSize) { _nudPage.Value currentPage; _lblCurrent.Text $第 {currentPage} 页; _lblTotal.Text $共 {totalCount} 条; _nudPage.Maximum Math.Max(1, totalPages); } // 公共方法启用/禁用按钮加载中时禁用 public void SetLoading(bool isLoading) { _btnFirst.Enabled _btnPrev.Enabled _btnNext.Enabled _btnLast.Enabled _btnGo.Enabled !isLoading; _nudPage.Enabled _cmbPageSize.Enabled !isLoading; } } // 事件参数类 public class PageChangedEventArgs : EventArgs { public int PageNumber { get; } public PageChangedEventArgs(int pageNumber) PageNumber pageNumber; } public class PageSizeChangedEventArgs : EventArgs { public int PageSize { get; } public PageSizeChangedEventArgs(int pageSize) PageSize pageSize; }逻辑说明这是一个纯 UI 控件不碰数据库只负责接收用户操作点按钮、输页码、选每页数并抛出事件UpdateState()方法由外部调用用于同步显示当前页、总页数、总条数SetLoading(true)会在数据加载时禁用所有交互控件防止用户狂点导致请求堆积所有按钮事件最终都抛出OnPageChanged或OnPageSizeChanged由宿主 Form 订阅处理。3.3 实现 IDataProvider适配你的 SQL 数据库以 Dapper 为例这才是分页的灵魂。你需要为自己的业务表写一个IDataProviderT实现。以下以Order实体为例使用 Dapper需 NuGet 安装Dapper// OrderDataProvider.cs using System; using System.Collections.Generic; using System.Data; using System.Linq; using System.Threading.Tasks; using Dapper; public class OrderDataProvider : IDataProviderOrder { private readonly IDbConnection _connection; public OrderDataProvider(IDbConnection connection) { _connection connection ?? throw new ArgumentNullException(nameof(connection)); } // 获取当前页数据 总数推荐一次查询返回两个结果集减少网络往返 public async Task(ListOrder, int) GetPageAsync(int skip, int take) { const string sql -- 结果集1当前页数据 SELECT Id, CustomerName, Amount, OrderDate, Status FROM Orders ORDER BY Id DESC OFFSET Skip ROWS FETCH NEXT Take ROWS ONLY; -- 结果集2总记录数可替换为 COUNT(1) 或 COUNT(*)根据需要优化 SELECT COUNT(1) FROM Orders;; using var multi await _connection.QueryMultipleAsync(sql, new { Skip skip, Take take }); var orders (await multi.ReadAsyncOrder()).ToList(); var totalCount await multi.ReadSingleAsyncint(); return (orders, totalCount); } // 可选单独的总数查询当无法用多结果集时使用 public async Taskint GetTotalCountAsync() { const string countSql SELECT COUNT(1) FROM Orders; return await _connection.QuerySingleAsyncint(countSql); } } // 实体类示例 public class Order { public int Id { get; set; } public string CustomerName { get; set; } public decimal Amount { get; set; } public DateTime OrderDate { get; set; } public string Status { get; set; } }参数说明GetPageAsync(skip, take)是核心skip (currentPage - 1) * pageSizetake pageSize使用OFFSET/FETCH是 SQL Server 2012 最标准的分页语法比ROW_NUMBER()更简洁、执行计划更优多结果集查询QueryMultipleAsync能将数据查询和总数查询合并为一次数据库往返性能提升显著如果你的 SQL Server 版本 2012或用 MySQL/PostgreSQL需改写为对应方言如 MySQL 用LIMIT Take OFFSET SkipGetTotalCountAsync()是兜底方案当多结果集不可用时调用。4. 在 Form 中集成三步完成附完整事件处理与异常防护现在把PagingControl拖到你的主窗体如MainForm.cs上按以下三步集成。这是最易出错的环节我们把每一步的“血泪经验”写进注释。4.1 步骤一声明成员变量与初始化连接// MainForm.cs public partial class MainForm : Form { private readonly IDbConnection _conn; private readonly IDataProviderOrder _dataProvider; private ListOrder _currentPageData new ListOrder(); public MainForm() { InitializeComponent(); // 1. 初始化数据库连接生产环境请用连接池此处简化 _conn new SqlConnection(Serverlocalhost;DatabaseMyAppDB;Trusted_Connectiontrue;); // 2. 创建数据提供者实例 _dataProvider new OrderDataProvider(_conn); // 3. 订阅分页控件事件 pagingControl1.OnPageChanged PagingControl1_OnPageChanged; pagingControl1.OnPageSizeChanged PagingControl1_OnPageSizeChanged; // 4. 加载第 1 页首次启动 LoadPage(1); } }注意SqlConnection必须在每次查询前打开查询后关闭。Dapper 会自动管理如果传入的是未打开的连接Dapper 会自动 Open/Close。但如果你用原生 ADO.NET务必确保connection.Open()在ExecuteReader前调用。4.2 步骤二实现页码变更事件处理含防抖与异步加载private async void PagingControl1_OnPageChanged(object sender, PageChangedEventArgs e) { try { // 1. UI 反馈显示加载中状态 pagingControl1.SetLoading(true); dataGridView1.DataSource null; // 清空旧数据避免闪烁 // 2. 计算 skip/take var pageSize Convert.ToInt32(pagingControl1.Controls.OfTypeComboBox() .FirstOrDefault(c c.Name _cmbPageSize)?.SelectedItem ?? 10); var skip (e.PageNumber - 1) * pageSize; // 3. 异步加载数据关键不能阻塞 UI 线程 var (data, totalCount) await _dataProvider.GetPageAsync(skip, pageSize); _currentPageData data; // 4. 绑定到 DataGridView dataGridView1.DataSource _currentPageData; // 5. 更新分页控件状态总页数 (totalCount pageSize - 1) / pageSize var totalPages (int)Math.Ceiling((double)totalCount / pageSize); pagingControl1.UpdateState(e.PageNumber, totalPages, totalCount, pageSize); } catch (Exception ex) { MessageBox.Show($加载第 {e.PageNumber} 页失败{ex.Message}, 数据加载错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { pagingControl1.SetLoading(false); } } private void PagingControl1_OnPageSizeChanged(object sender, PageSizeChangedEventArgs e) { // 每页条数改变时重置到第 1 页符合用户直觉 LoadPage(1); } private async void LoadPage(int page) { // 封装为独立方法方便复用 var ev new PageChangedEventArgs(page); PagingControl1_OnPageChanged(null, ev); }关键细节dataGridView1.DataSource null必须在异步开始前执行否则 DataGridView 可能因绑定中状态导致 UI 卡顿Math.Ceiling((double)totalCount / pageSize)是计算总页数的标准公式避免整除导致最后一页漏数据try/catch/finally保证无论成功失败SetLoading(false)都会被调用防止按钮永远禁用错误提示用MessageBox.Show而非Console.WriteLine这是 WinForm 用户能感知的反馈。4.3 步骤三增强健壮性——增加查询条件过滤与空结果处理真实场景中用户会输入搜索关键词。我们在OrderDataProvider中扩展一个SearchTerm属性并修改 SQL// OrderDataProvider.cs增强版 public class OrderDataProvider : IDataProviderOrder { private readonly IDbConnection _connection; public string SearchTerm { get; set; } ; // 外部可设置 public OrderDataProvider(IDbConnection connection) _connection connection; public async Task(ListOrder, int) GetPageAsync(int skip, int take) { string whereClause string.IsNullOrWhiteSpace(SearchTerm) ? : WHERE CustomerName LIKE SearchTerm OR CAST(Id AS VARCHAR) LIKE SearchTerm; const string sql -- 分页数据 SELECT Id, CustomerName, Amount, OrderDate, Status FROM Orders {0} ORDER BY Id DESC OFFSET Skip ROWS FETCH NEXT Take ROWS ONLY; -- 总数注意WHERE 条件必须一致 SELECT COUNT(1) FROM Orders {0};; var fullSql string.Format(sql, whereClause); var param new DynamicParameters(); param.Add(Skip, skip, DbType.Int32); param.Add(Take, take, DbType.Int32); if (!string.IsNullOrWhiteSpace(SearchTerm)) param.Add(SearchTerm, $%{SearchTerm}%, DbType.String); using var multi await _connection.QueryMultipleAsync(fullSql, param); var orders (await multi.ReadAsyncOrder()).ToList(); var totalCount await multi.ReadSingleAsyncint(); return (orders, totalCount); } }然后在 Form 中当用户点击“搜索”按钮时private void btnSearch_Click(object sender, EventArgs e) { // 设置搜索词 ((OrderDataProvider)_dataProvider).SearchTerm txtSearch.Text.Trim(); // 重载第 1 页 LoadPage(1); }血泪经验WHERE 条件必须在数据查询和总数查询中完全一致否则总页数算错跳页会错乱。用DynamicParameters构造参数杜绝字符串拼接 SQL防止 SQL 注入。5. 避坑指南WinForm 分页控件落地中 4 个高频翻车点与解法这些不是理论问题是我在三个不同客户现场亲手 debug 过的真实案例。每一条都附带“现象 → 原因 → 解决”闭环。5.1 现象点击“下一页”后DataGridView 显示空白但GetPageAsync返回的数据明明有 100 条原因DataGridView的AutoGenerateColumns属性为true但你的Order实体有[Column(OrderDate)]特性或字段名与数据库列名不一致如数据库是order_dateC# 是OrderDate导致 Dapper 映射失败返回的ListOrder全是null。解决① 关闭自动列生成dataGridView1.AutoGenerateColumns false;② 手动定义列在设计器或代码中dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName OrderDate, HeaderText 下单时间, Width 120 });③ 或统一命名数据库列用 PascalCaseOrderDateC# 实体同名Dapper 默认按名称匹配。5.2 现象快速连点“下一页”按钮页面跳到第 5 页但数据显示的是第 3 页的内容原因异步请求未加防抖debounce。用户点第 2 页 → 请求发出未等返回又点第 3 页 → 新请求发出第 2 页数据先返回覆盖了第 3 页的DataSource。解决在PagingControl1_OnPageChanged中加入请求 ID 标记private int _currentRequestId 0; private async void PagingControl1_OnPageChanged(object sender, PageChangedEventArgs e) { var requestId Interlocked.Increment(ref _currentRequestId); try { // ... 加载逻辑 ... var (data, totalCount) await _dataProvider.GetPageAsync(skip, pageSize); // 检查是否是最新请求的结果 if (requestId ! _currentRequestId) return; // 丢弃旧请求结果 dataGridView1.DataSource data; // ... 更新状态 } // ... }5.3 现象SQL Server 报错 “OFFSET 附近有语法错误”但确认版本是 2016原因数据库兼容级别低于 110SQL Server 2012 对应级别 110。OFFSET/FETCH要求兼容级别 ≥ 110。解决① 查看当前兼容级别SELECT compatibility_level FROM sys.databases WHERE name MyAppDB② 升级兼容级别需 DBA 权限ALTER DATABASE MyAppDB SET COMPATIBILITY_LEVEL 130; -- SQL Server 2016③ 若无法升级改用ROW_NUMBER()方案性能稍差WITH Paged AS ( SELECT *, ROW_NUMBER() OVER (ORDER BY Id DESC) AS RowNum FROM Orders WHERE [条件] ) SELECT Id, CustomerName, Amount, OrderDate, Status FROM Paged WHERE RowNum BETWEEN Skip 1 AND Skip Take;5.4 现象分页控件在高 DPI 显示器上文字模糊、按钮错位原因WinForm 默认不启用 DPI 感知Windows 缩放如 125%会导致控件渲染失真。解决在Program.cs的Main方法顶部添加[STAThread] static void Main() { // 启用 DPI 感知.NET Framework 4.7 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } // P/Invoke 声明 [DllImport(user32.dll)] private static extern bool SetProcessDpiAwarenessContext(IntPtr value); private const IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (IntPtr)(-4);并在app.config中添加configuration system.windows.forms dpiAwareTrue dpiAwarenessPerMonitorV2 / /configuration6. 进阶技巧让分页控件支持“记住上次位置”与“导出当前页”一键能力做完基础分页用户常提两个需求“我昨天看到第 42 页今天打开还想在那”和“把这页数据导出 Excel”。这两个功能加起来不到 20 行代码却极大提升体验。6.1 记住上次页码用 Properties.Settings 持久化WinForm 自带Settings.settings文件可存用户级配置。① 右键项目 → “属性” → “设置”选项卡 → 添加新设置名称LastPageNumber类型int范围User值1② 在MainForm构造函数末尾读取// 加载后恢复页码 if (Properties.Settings.Default.LastPageNumber 0) { LoadPage(Properties.Settings.Default.LastPageNumber); }③ 在PagingControl1_OnPageChanged成功后保存// 在 finally 前成功加载后 Properties.Settings.Default.LastPageNumber e.PageNumber; Properties.Settings.Default.Save(); // 立即写入提示Properties.Settings.Default.Save()会将值写入C:\Users\[用户名]\AppData\Local\[公司名]\[程序名]_[哈希]\[版本]\user.config安全可靠无需自己写文件。6.2 导出当前页数据为 Excel用 ClosedXML零依赖、免 OfficeNuGet 安装ClosedXML比 EPPlus 更轻量无商业授权风险。添加导出按钮事件private void btnExport_Click(object sender, EventArgs e) { if (_currentPageData null || _currentPageData.Count 0) return; try { using var wb new XLWorkbook(); var ws wb.Worksheets.Add(订单列表); // 写入表头反射获取属性名 var props typeof(Order).GetProperties(); for (int i 0; i props.Length; i) { ws.Cell(1, i 1).Value props[i].Name; ws.Cell(1, i 1).Style.Font.Bold true; } // 写入数据 for (int row 0; row _currentPageData.Count; row) { for (int col 0; col props.Length; col) { var value props[col].GetValue(_currentPageData[row]); ws.Cell(row 2, col 1).Value value?.ToString() ?? ; } } // 自动列宽 ws.Columns().AdjustToContents(); // 保存对话框 using var sfd new SaveFileDialog { Filter Excel 文件 (*.xlsx)|*.xlsx, FileName $订单列表_第{pagingControl1.Controls.OfTypeNumericUpDown().First().Value}页.xlsx }; if (sfd.ShowDialog() DialogResult.OK) { wb.SaveAs(sfd.FileName); MessageBox.Show(导出成功, 提示, MessageBoxButtons.OK, MessageBoxIcon.Information); } } catch (Exception ex) { MessageBox.Show($导出失败{ex.Message}, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } }参数说明ClosedXML不依赖 Excel 应用程序纯 .NET 实现服务器环境也能用ws.Columns().AdjustToContents()自动适应内容宽度避免中文被截断文件名包含页码方便用户区分导出的是当前ListOrder即用户看到的这一页不是全量数据符合预期。6.3 最后一句经验别迷信“全自动分页控件”我见过太多团队花两周集成一个号称“智能解析 SQL”的分页控件结果发现它不支持LEFT JOIN或者COUNT(*)算错最后还是回到手写GetPageAsync。真正的生产力不在于控件多“智能”而在于你对OFFSET/FETCH的理解有多深、对DynamicParameters的运用有多熟、对DataGridView的生命周期有多敬畏。这个PagingControl我在三个不同行业制造业 MES、医疗 HIS、物流 TMS的 WinForm 项目中复用过每次只需改IDataProvider的 SQL 和实体映射从未因分页逻辑出过线上故障。它不炫技但稳如磐石。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进