
这个案例是上位机开发中出现频率最高的功能之一操作员按时间段查询历史数据然后导出成 Excel 交给生产主管。这里从工控思维映射的角度拆解。 案例目标温度历史记录查询与导出场景延续上一个案例中程序每 500ms 采集一次温度。现在需要把每次采集写入数据库然后提供一个界面让操作员选择起止时间查询并导出 Excel。核心映射PLC 的数据块 → 数据库表PLC 的归档功能 → SQL 查询上位机的报表 → Excel 文件。️ 第一步数据表设计映射 PLC 数据块在 SQL Server 中建一张历史表。注意字段类型的选择直接对应工控数据的精度需求CREATETABLETemperatureHistory(IdBIGINTIDENTITY(1,1)PRIMARYKEY,RecordTime DATETIME2(3)NOTNULL,-- 精确到毫秒对应采集时间戳TemperatureFLOATNOTNULL,-- 对应 PLC 的 REAL 类型IsAlarmBITNOTNULL-- 对应 PLC 的 BOOL 报警位);CREATEINDEXIX_RecordTimeONTemperatureHistory(RecordTime);工控要点DATETIME2(3)比DATETIME精度高工控采集常需要毫秒级时间戳。RecordTime上必须建索引。历史表动辄几百万行没有索引的按时间查询会直接超时。不要在查询时用DateTime.Now要用采集时刻的时间戳否则网络延迟会导致时间偏差。️ 第二步界面设计在 Form 上拖拽控件Name说明DateTimePickerdtpStart起始时间Format 设为CustomCustomFormat 设为yyyy-MM-dd HH:mm:ssDateTimePickerdtpEnd结束时间同上ButtonbtnQuery查询ButtonbtnExport导出 ExcelDataGridViewdgvData显示查询结果LabellblStatus显示“共查询到 N 条记录” 第三步查询逻辑映射 PLC 的归档读取usingSystem;usingSystem.Data;usingSystem.Data.SqlClient;usingSystem.Windows.Forms;namespaceHistoryQueryApp{publicpartialclassForm1:Form{// 【映射】数据库连接字符串 通信层的IP 地址privatereadonlystring_connStrServerlocalhost;DatabaseFactoryDB;Integrated SecurityTrue;;publicForm1(){InitializeComponent();dtpStart.ValueDateTime.Now.AddHours(-8);// 默认查最近8小时dtpEnd.ValueDateTime.Now;}privatevoidbtnQuery_Click(objectsender,EventArgse){// 【工控要点1】时间校验起始不能晚于结束if(dtpStart.ValuedtpEnd.Value){MessageBox.Show(起始时间必须早于结束时间,参数错误);return;}// 【工控要点2】查询范围保护防止一次拉取过多数据卡死界面TimeSpanrangedtpEnd.Value-dtpStart.Value;if(range.TotalDays31){MessageBox.Show(单次查询范围不能超过31天请缩小时间范围。);return;}try{DataTabledtQueryHistory(dtpStart.Value,dtpEnd.Value);dgvData.DataSourcedt;lblStatus.Text$共查询到{dt.Rows.Count}条记录;}catch(Exceptionex){// 【映射】通信异常处理数据库断开就像 PLC 断线MessageBox.Show($查询失败{ex.Message},通信错误);}}privateDataTableQueryHistory(DateTimestart,DateTimeend){// 【工控要点3】参数化查询防止 SQL 注入也避免日期格式解析错误stringsqlSELECT RecordTime AS [时间], Temperature AS [温度(°C)], CASE WHEN IsAlarm 1 THEN N报警 ELSE N正常 END AS [状态] FROM TemperatureHistory WHERE RecordTime Start AND RecordTime End ORDER BY RecordTime ASC;using(varconnnewSqlConnection(_connStr))using(varcmdnewSqlCommand(sql,conn))using(varadapternewSqlDataAdapter(cmd)){cmd.Parameters.Add(Start,SqlDbType.DateTime2).Valuestart;cmd.Parameters.Add(End,SqlDbType.DateTime2).Valueend;vardtnewDataTable();conn.Open();adapter.Fill(dt);returndt;}}}}工控避坑不要用字符串拼接 SQL。WHERE RecordTime 2024-01-01在英文系统上可能解析失败参数化查询是唯一正确做法。查询要有上限保护。操作员可能手滑选了“最近一年”几百万行数据直接让DataGridView假死。真实项目中要么限制范围要么分页查询。数据库连接用using。工控现场数据库可能随时重启连接必须及时释放否则连接池耗尽后整个系统瘫痪。 第四步导出 Excel映射报表生成工控开发中导出 Excel 有三条路线选择取决于部署环境方案优点缺点适用场景NPOI推荐免费、无需装 Office、支持 .xlsxAPI 稍繁琐绝大多数工控上位机ClosedXML免费、API 更简洁只支持 .xlsx新项目Office InteropAPI 最直观必须装 Office、进程残留风险不推荐工控现场强烈推荐 NPOI。工控现场电脑往往不装 OfficeInterop 方案会直接报错。通过 NuGet 安装NPOI。usingNPOI.SS.UserModel;usingNPOI.XSSF.UserModel;usingSystem.IO;privatevoidbtnExport_Click(objectsender,EventArgse){// 【工控要点4】导出前检查有没有数据if(dgvData.DataSourcenull||dgvData.Rows.Count0){MessageBox.Show(没有可导出的数据请先查询。);return;}using(varsfdnewSaveFileDialog()){sfd.FilterExcel 文件|*.xlsx;sfd.FileName$温度记录_{dtpStart.Value:yyyyMMddHHmm}_{dtpEnd.Value:yyyyMMddHHmm}.xlsx;if(sfd.ShowDialog()!DialogResult.OK)return;try{ExportToExcel((DataTable)dgvData.DataSource,sfd.FileName);MessageBox.Show(导出成功,提示);// 【工控要点5】可选导出后自动打开文件方便操作员立即查看System.Diagnostics.Process.Start(sfd.FileName);}catch(Exceptionex){// 【工控要点6】导出失败的典型原因文件被 Excel 占用MessageBox.Show($导出失败{ex.Message}\n请确认文件未被其他程序打开。,错误);}}}privatevoidExportToExcel(DataTabledt,stringfilePath){IWorkbookworkbooknewXSSFWorkbook();// .xlsx 格式ISheetsheetworkbook.CreateSheet(温度历史);// 写表头IRowheaderRowsheet.CreateRow(0);for(inti0;idt.Columns.Count;i){headerRow.CreateCell(i).SetCellValue(dt.Columns[i].ColumnName);}// 写数据for(intr0;rdt.Rows.Count;r){IRowrowsheet.CreateRow(r1);for(intc0;cdt.Columns.Count;c){varcellrow.CreateCell(c);objectvaluedt.Rows[r][c];// 【工控要点7】按类型写入否则 Excel 里全是文本无法做图表if(valueisDateTimedtVal){cell.SetCellValue(dtVal.ToString(yyyy-MM-dd HH:mm:ss.fff));}elseif(valueisdoubledVal){cell.SetCellValue(dVal);// 写为数值Excel 可直接计算}else{cell.SetCellValue(value?.ToString()??);}}}// 【工控要点8】自动列宽否则时间列显示为 ######for(inti0;idt.Columns.Count;i){sheet.AutoSizeColumn(i);// NPOI 的 AutoSizeColumn 对中文支持不好手动加宽sheet.SetColumnWidth(i,sheet.GetColumnWidth(i)1024);}using(varfsnewFileStream(filePath,FileMode.Create,FileAccess.Write)){workbook.Write(fs);}} 调试要点用断点验证数据流这个案例的调试重点在时间边界和数据类型转换两处1. 时间边界断点在QueryHistory方法的adapter.Fill(dt)之后设断点。运行后把dtpStart设为10:00:00dtpEnd设为10:00:01。观察dt.Rows.Count是否包含了10:00:00.500这条记录。这验证的是和的边界逻辑——工控查询中“起止时间是否包含端点”经常引发争议。2. 数据类型监视把dt添加到监视窗口展开第一行观察Temperature列的类型是double还是string。如果是string说明 SQL 查询里做了隐式转换导出到 Excel 后会变成文本格式无法画趋势图。这时要回头检查SELECT语句和数据库字段类型。3. 立即窗口验证导出前在立即窗口输入((DataTable)dgvData.DataSource).Rows.Count确认导出的是当前查询结果而不是全部历史数据。工控现场最常见的事故就是操作员查了 1 小时的数据导出时却把整张表导出去了。 本案例的工控思维提炼1. 查询是“读归档”导出是“做报表”PLC 的归档功能对应数据库的历史表上位机的查询就是模拟 PLC 编程软件里的“趋势图”功能。区别在于上位机要面对操作员不可控的输入比如选了 3 个月范围所以必须加保护。2. 导出格式影响下游使用如果导出成文本格式的数字生产主管拿到 Excel 后无法求和、无法画图会返工。在导出时就写成数值类型是上位机开发者的基本功。3. 文件占用是工控现场的经典故障操作员打开 Excel 查看后忘了关闭下一次导出直接失败。捕获异常并给出人话提示“请确认文件未被其他程序打开”比抛出一堆英文堆栈有用得多。4. 大数据量要分页或异步如果查询确实可能返回几十万行DataGridView绑定会卡死。下一步的进化方向是查询用Task.Run放到后台线程界面显示“查询中…”进度或者改用分页查询每次只取 1000 行。