☰
工业级温室监控上位机:Modbus通信、断网缓存与报警状态机设计
2026/10/28 1:04:14 网站建设 项目流程

简介:这是一套基于C#开发的温室环境监控系统上位机源码,面向嵌入式初学者、自动化专业学生及农业物联网项目实践者,解决温室温湿度、光照等多参数实时采集、可视化监控与远程指令下发的核心问题。资源包共1065个文件,含298个运行依赖DLL、266个配置与文档XML、210个临时或隐藏文件(如._开头)、86个说明类TXT及30个构建目标文件targets,辅以PNG界面图、CS核心逻辑代码、C/H底层通信文件、HEX固件与完整VS解决方案(.sln),整体50.71MB,结构覆盖UI层、通信层、控制策略与数据存储模块。已有2318人学习下载,提供可直接编译运行的完整工程,包含串口/TCP双通信适配、传感器数据解析逻辑、阈值告警机制、WPF人机交互界面及51普中下位机协同控制范例,是理解上位机-单片机协同架构与农业自动化落地的优质实操样本。

1. 温室监控上位机不是“串口读个温度就完事”:它得扛住传感器掉线、数据突变、断电重启、多设备混接,还要让农技员在平板上点两下就看懂趋势

很多人第一次做温室监控上位机,以为就是用 C# 写个 WinForm 窗体,拖几个 TextBox 和 Chart 控件,串口一连、定时器一跑,温度湿度曲线哗哗出来——答辩前一周才发现:现场部署时,RS485 总线上挂了 8 路 DHT22+3 路光照+2 路土壤 EC 值,其中 2 路传感器隔天就失联;断网后本地历史数据存到一半崩溃;农技员反馈“曲线太密看不清哪天浇过水”,而管理员又要求导出 Excel 时必须带设备编号、校准时间、操作人签名字段。这套 C# 上位机源码,就是为解决这类真实交付场景而设计的:它不是教学 Demo,而是按工业级数据采集系统标准搭的骨架——支持 Modbus RTU/ASCII 双协议自适应解析、断网缓存+断线重连双保险机制、带版本标记的 SQLite 本地数据库、可配置报警阈值与联动动作(如超温自动弹窗+继电器控制信号输出)、以及导出报表时自动嵌入设备元数据与操作日志水印。适合高校课程设计需体现工程规范、毕设要过中期检查与终期答辩、或小型农业物联网项目直接拿去二次开发的开发者。你不需要从零写串口通信,但得知道为什么这里用SerialPort而不是System.IO.Ports新 API,为什么报警状态要进数据库而不只存在内存里。


2. 总体架构设计:四层结构与五个核心模块的职责边界

这套温室监控上位机采用清晰分层架构,不是把所有逻辑塞进 Form1.cs 里硬刚。我把它拆成四层:硬件交互层 → 数据服务层 → 业务逻辑层 → 表现层。每层有明确输入输出契约,改串口驱动不影响图表渲染,换数据库不碰报警规则。这种结构在答辩时能一句话讲清“为什么这个类不能放在这里”,比堆代码更有说服力。

2.1 硬件交互层:串口通信不是开个端口就完事,它得会“听懂方言”

硬件交互层不只负责发指令、收字节,更要处理协议语义。本方案默认支持 Modbus RTU(主流温湿度变送器用)和 Modbus ASCII(部分老式光照仪用),通过帧头识别自动切换解析方式。关键不是“能通”,而是“通得稳”。

// SerialPortManager.cs 核心片段:带心跳检测与协议自适应 public class SerialPortManager { private SerialPort _port; private readonly Dictionary<string, ModbusProtocol> _protocolCache = new(); public bool OpenPort(string portName, int baudRate = 9600) { try { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One); _port.ReadTimeout = 500; // 关键!不能设成 Infinite,否则卡死 _port.WriteTimeout = 300; _port.DataReceived += OnDataReceived; // 异步接收,避免 UI 线程阻塞 _port.Open(); // 启动心跳线程:每10秒发一次空闲查询,检测设备在线状态 Task.Run(() => HeartbeatLoop()); return true; } catch (Exception ex) { Log.Error($"串口打开失败: {portName} - {ex.Message}"); return false; } } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var buffer = new byte[256]; int len = _port.Read(buffer, 0, buffer.Length); if (len == 0) return; // 自适应协议识别:RTU 以 0x01 开头且含 CRC 校验,ASCII 以 ':' 开头 var frame = buffer.Take(len).ToArray(); var protocol = DetectProtocol(frame); var parsed = ModbusParser.Parse(frame, protocol); // 解析成功才触发事件,避免脏数据污染上层 DataReceived?.Invoke(this, new DataReceivedEventArgs(parsed)); } }

提示:ReadTimeout设为 500ms 是血泪经验。曾有个项目设成Infinite,某次传感器短路导致串口一直等不到结束符,整个 UI 线程卡死,用户只能强制结束进程。500ms 足够收完一帧 Modbus(最长约 25 字节),超时就丢弃,保证主线程响应性。

2.2 数据服务层:SQLite 不是“临时存一下”,它是带事务、索引、版本号的本地数据中心

很多同学用List<T>存历史数据,答辩时被问“断电后数据丢了怎么办?”当场哑火。本方案用 SQLite 作为本地持久化核心,但不是简单INSERT INTO data ...。它包含三张关键表:

表名作用关键设计点
device_config存设备地址、型号、量程、校准系数、最后通信时间last_online_time字段用于判断设备是否离线(>5分钟未更新即标红)
sensor_data存原始采集值(含时间戳、设备ID、原始AD值、单位转换后值)created_at用DateTime.UtcNow避免时区混乱;is_valid标记是否经异常值过滤
alarm_log存每次报警触发记录(类型、设备、阈值、实际值、处理状态)handled_by和handle_time支持人工闭环追踪
-- sensor_data 表建表语句(含索引优化) CREATE TABLE sensor_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, raw_value REAL NOT NULL, converted_value REAL NOT NULL, unit TEXT NOT NULL, is_valid BOOLEAN DEFAULT 1, created_at DATETIME DEFAULT (datetime('now', 'utc')), FOREIGN KEY (device_id) REFERENCES device_config(device_id) ); -- 关键索引:按设备+时间范围查历史数据,毫秒级响应 CREATE INDEX idx_device_time ON sensor_data(device_id, created_at);

注意:SQLite 的datetime('now', 'utc')比DateTime.Now.ToString()安全得多。后者依赖系统时区,若服务器时区错配,导出报表时间全乱;UTC 时间统一,前端展示时再转本地时区,逻辑干净。

2.3 业务逻辑层:报警不是“if (temp > 35) MessageBox.Show()”,它得可配置、可追溯、可联动

报警模块是验收重点。本方案把报警规则抽成独立类AlarmRuleEngine,规则存在数据库,界面可动态增删改,无需改代码。规则支持复合条件(如“温度 >35℃ 且湿度 <40% 持续 5 分钟”),并关联动作:弹窗提醒、写日志、触发继电器输出(通过串口发 Modbus 写指令)、邮件通知(需配置 SMTP)。

// AlarmRule.cs 定义一条规则 public class AlarmRule { public int Id { get; set; } public string DeviceId { get; set; } // 绑定设备 public string Metric { get; set; } // 监控指标:temperature, humidity... public string Condition { get; set; } // "gt", "lt", "between" public double Threshold1 { get; set; } // 如 35.0 public double Threshold2 { get; set; } // 如 40.0(用于 between) public int DurationSeconds { get; set; } // 持续时间,单位秒 public bool IsActive { get; set; } // 是否启用 public List<AlarmAction> Actions { get; set; } // 动作列表 } // AlarmAction.cs 定义一个动作 public class AlarmAction { public ActionType Type { get; set; } // Popup, RelayControl, Email... public string Target { get; set; } // 如 "COM4:0x01:0x05" 表示向 COM4 的 0x01 设备写 0x05 线圈 public string Content { get; set; } // 邮件正文模板 }

规则引擎每 2 秒扫描一次最新数据,用滑动窗口计算持续时间。这不是轮询,而是状态机:进入预警态 → 计时开始 → 持续满足 → 触发动作 → 进入已触发态 → 直到值恢复正常并维持 30 秒才重置。避免抖动误报。

2.4 表现层:WinForm 不是“丑但能用”,它得让非技术人员一眼看懂关键信息

表现层用 WinForm(兼容 .NET Framework 4.7.2+,不强求 .NET 6),但做了三处关键增强:

  • 主监控页:顶部状态栏显示“总设备数/在线数/报警数”,右侧实时刷新“最近报警”滚动条(非弹窗轰炸);
  • 趋势图:用 LiveCharts(轻量、无 WPF 依赖),支持双 Y 轴(温度左轴、湿度右轴),鼠标悬停显示精确值+时间戳;
  • 设备管理页:表格支持按“在线状态”“报警次数”“最后通信时间”排序,点击设备行可快速跳转到其历史曲线页。

避坑 / 常见问题 / 排查 / 注意

2.5 串口通信与 Modbus 协议落地五大翻车点

现象 1:串口能连上,但收不到数据,或数据乱码
原因:波特率/校验位/停止位与设备手册不一致;或设备发送的是 ASCII 帧,代码却按 RTU 解析(CRC 校验失败丢弃)。
解决:先用串口调试助手(如 XCOM)抓原始帧,确认帧格式;在DetectProtocol方法中加日志,打印前 10 字节十六进制,比对标准 Modbus 帧结构。

现象 2:Modbus 读寄存器返回异常码 0x02(非法地址)
原因:请求的寄存器地址超出设备支持范围(如请求 0x0010,但设备只开放 0x0000~0x000F);或地址偏移计算错误(有些设备地址从 0 开始,有些从 1)。
解决:查设备手册确认地址映射表;代码中统一用“功能码+起始地址+数量”封装请求,避免手算地址。

现象 3:多设备轮询时,某台设备响应慢导致后续设备超时
原因:串口是共享资源,轮询时未加设备级超时,一台卡住拖垮全局。
解决:为每个设备设置独立超时(如 300ms),用CancellationTokenSource控制单次读取,超时即跳过该设备,继续下一轮。

现象 4:SQLite 插入大量数据时界面卡顿
原因:每条数据都开事务、执行 INSERT,I/O 频繁。
解决:批量插入(每 100 条包一个事务),用INSERT INTO ... VALUES (...),(...)多值语法;或用SqliteCommand.Prepare()预编译。

现象 5:程序重启后,历史数据时间戳全变成启动时刻
原因:DateTime.Now在程序启动时获取一次,后续所有数据用同一时间戳。
解决:created_at字段必须在INSERT语句中用datetime('now', 'utc'),或 C# 层每次插入前调用DateTime.UtcNow。


3. 核心模块实现:从串口收包到报警弹窗的完整链路

这一章带你走一遍数据从传感器到用户眼睛的完整旅程。不是贴一堆代码,而是告诉你每个环节为什么这么写、参数怎么调、失败怎么看。

3.1 串口数据接收与 Modbus 解析:如何把一串十六进制变成可用的温度值

数据流起点是SerialPortManager.OnDataReceived。当串口收到字节,它不急着解析,先做三件事:

  1. 帧完整性校验:RTU 帧末尾有 2 字节 CRC,ASCII 帧有 2 字节 LRC,先验证校验和。失败则丢弃整帧,不传给上层。
  2. 协议识别:RTU 帧无起始符,但长度固定(最少 8 字节:地址+功能码+数据+2字节CRC);ASCII 帧以:开头,以\r\n结束。用正则^:[0-9A-Fa-f]{2,}$匹配 ASCII。
  3. 地址路由:一帧可能含多个设备数据(广播帧除外),解析出设备地址slave_id,只将数据发给对应设备的处理器。
// ModbusParser.cs 片段:解析 RTU 帧的核心逻辑 public static ModbusFrame Parse(byte[] frame, ModbusProtocol protocol) { if (protocol == ModbusProtocol.ASCII) { var hexStr = Encoding.ASCII.GetString(frame).Trim(':', '\r', '\n'); var bytes = Enumerable.Range(0, hexStr.Length / 2) .Select(i => Convert.ToByte(hexStr.Substring(i * 2, 2), 16)) .ToArray(); return ParseRtu(bytes); // ASCII 解码后转 RTU 解析 } // RTU 解析:直接处理字节数组 if (frame.Length < 8) throw new InvalidDataException("RTU frame too short"); var slaveId = frame[0]; var functionCode = frame[1]; var dataLength = frame.Length - 5; // 减去地址、功能码、CRC(2字节) // CRC 校验(标准 Modbus CRC16) if (!IsValidCrc(frame, frame.Length - 2)) throw new InvalidDataException("CRC check failed"); // 提取数据区(去掉地址、功能码、CRC) var data = frame.Skip(2).Take(dataLength).ToArray(); return new ModbusFrame { SlaveId = slaveId, FunctionCode = functionCode, Data = data, RawBytes = frame }; }

设计说明:ParseRtu(bytes)是复用逻辑,因为 ASCII 帧解码后本质还是 RTU 数据结构。这样避免写两套解析器,降低维护成本。IsValidCrc用查表法实现,比循环计算快 10 倍,对高频采集很关键。

3.2 数据清洗与单位转换:为什么原始 AD 值要进数据库,而显示值要单独存

传感器返回的是原始 AD 值(如 0~4095),但用户要看的是 ℃ 或 %RH。转换公式因设备而异:DHT22 是查表+线性插值,SHT30 是多项式拟合,土壤 EC 传感器需乘以探头系数。本方案坚持两个原则:

  • 原始值必存:raw_value字段存 AD 值,永不丢失。这是溯源依据,校准、调试、故障分析全靠它。
  • 转换值可配:converted_value由device_config表中的calibration_formula字段定义,支持三种模式:
    • Linear:y = a * x + b(存 a,b 两参数)
    • Polynomial:y = a*x² + b*x + c(存 a,b,c)
    • LookupTable: JSON 字符串存{ "0":20.1, "100":20.5, ... },插值计算
// DataProcessor.cs:单位转换入口 public double ConvertRawValue(int deviceId, double rawValue) { var config = _deviceService.GetConfig(deviceId); return config.CalibrationType switch { "Linear" => config.A * rawValue + config.B, "Polynomial" => config.A * Math.Pow(rawValue, 2) + config.B * rawValue + config.C, "LookupTable" => InterpolateFromJson(config.LookupTableJson, rawValue), _ => rawValue // 默认不转 }; } // InterpolateFromJson 示例:简化版线性插值 private double InterpolateFromJson(string json, double x) { var table = JsonConvert.DeserializeObject<Dictionary<double, double>>(json); var keys = table.Keys.OrderBy(k => k).ToArray(); for (int i = 0; i < keys.Length - 1; i++) { if (x >= keys[i] && x <= keys[i + 1]) { var t = (x - keys[i]) / (keys[i + 1] - keys[i]); return table[keys[i]] * (1 - t) + table[keys[i + 1]] * t; } } return table[keys[0]]; // 超出范围取首值 }

参数说明:calibration_formula字段不存公式字符串(如"0.01*x+20"),而存结构化参数。因为公式字符串需Eval执行,有安全风险且难调试;结构化参数可校验、可序列化、可审计。

3.3 报警状态机与动作执行:如何避免“报警弹窗关不掉”和“继电器反复开关”

报警不是布尔开关,而是有生命周期的状态机。本方案定义五种状态:

状态触发条件持续行为退出条件
Idle初始态无动作数据越限
Warning首次越限计时器启动持续越限达阈值时间
Active计时完成执行所有动作数据恢复正常并维持 30 秒
Acknowledged用户点击“已知悉”停止弹窗,保留日志手动清除或超时自动清除
Cleared数据正常+30秒状态归零下次越限重新进入
// AlarmStateMachine.cs:状态流转核心 public void ProcessData(SensorData data) { var rule = _ruleService.GetActiveRuleFor(data.DeviceId, data.Metric); if (rule == null) return; switch (_currentState) { case AlarmState.Idle: if (IsConditionMet(data, rule)) StartWarningTimer(rule); break; case AlarmState.Warning: if (!IsConditionMet(data, rule)) Reset(); // 条件不满足,重置 else if (IsDurationReached(rule)) TriggerActiveState(rule, data); break; case AlarmState.Active: if (!IsConditionMet(data, rule) && IsStableNormal(data, rule)) ClearActiveState(); break; } } private void TriggerActiveState(AlarmRule rule, SensorData data) { _currentState = AlarmState.Active; _alarmLogService.CreateLog(new AlarmLog { RuleId = rule.Id, DeviceId = data.DeviceId, ActualValue = data.ConvertedValue, Threshold = rule.Threshold1, TriggeredAt = DateTime.UtcNow }); // 并行执行所有动作,互不阻塞 Parallel.ForEach(rule.Actions, action => ExecuteAction(action, data)); }

设计说明:Parallel.ForEach执行动作,是因为继电器控制(串口发指令)和邮件发送(网络 I/O)耗时差异大,串行会拖慢整体响应。每个动作有独立超时(继电器 500ms,邮件 10s),失败不中断其他动作。

3.4 历史数据查询与图表渲染:如何让 30 天数据秒出,且缩放不卡死

LiveCharts 默认加载全部数据点,30 天按 10 秒一采就是 25.9 万点,WinForm 直接卡死。本方案用两级缓存+动态采样:

  • 一级缓存:内存中存最近 1 小时数据(约 360 点),实时更新;
  • 二级缓存:SQLite 中存全量数据,但查询时按时间范围智能降采样。
// ChartDataService.cs:智能降采样查询 public List<SensorDataPoint> GetChartData(string deviceId, DateTime from, DateTime to, int maxPoints = 1000) { var totalMinutes = (to - from).TotalMinutes; var interval = Math.Max(1, (int)Math.Ceiling(totalMinutes / maxPoints)); // 每 interval 分钟取一点 // SQL 实现降采样:按时间分组,取每组第一条(或平均值) var sql = $@" SELECT MIN(created_at) as time, AVG(converted_value) as value FROM sensor_data WHERE device_id = @deviceId AND created_at BETWEEN @from AND @to GROUP BY strftime('%Y-%m-%d %H:%M', created_at, '-{interval} minutes') ORDER BY time"; return _sqliteService.Query<SensorDataPoint>(sql, new { deviceId, from, to }); }

参数说明:maxPoints=1000是经验值,LiveCharts 渲染千点以内流畅;interval动态计算,查 1 小时数据用 1 分钟间隔(60 点),查 30 天用 43 分钟间隔(约 1000 点)。strftime分组是 SQLite 内置函数,比 C# 层分组快 5 倍。

避坑 / 常见问题 / 排查 / 注意

3.5 图表与数据可视化三大玄学问题

现象 1:图表 X 轴时间显示为“1970-01-01”或数字
原因:LiveCharts 的DateTimeAxis要求DateTime类型数据,但 SQL 查询返回的是字符串(如"2023-05-01 10:30:00"),未正确转换。
解决:在SensorDataPoint类中,time属性声明为DateTime,用 Dapper 的Query<T>会自动转换;若手动ExecuteReader,需用GetDateTime()而非GetString()。

现象 2:缩放图表时,数据点突然消失或错位
原因:LiveCharts 的ZoomingOptions未启用EnableZooming,EnablePanning,或缩放后未调用ChartView.Update(true)强制重绘。
解决:初始化时设置chart.ZoomingOptions = new ZoomingOptions { EnableZooming = true, EnablePanning = true };;缩放事件中调用chart.Update(true)。

现象 3:导出 PNG 图片模糊、文字锯齿
原因:LiveCharts 默认渲染分辨率低,且未设置UseLayoutRounding="True"。
解决:在 XAML 中为CartesianChart添加属性UseLayoutRounding="True";导出时用chart.ExportToImage()并指定高 DPI(如new Size(1920, 1080))。


4. 工程化增强:断网缓存、多设备管理、报表导出与权限控制

课程设计常忽略“交付后的事”:客户断网了怎么办?设备多了怎么管?领导要报表怎么交?本章补全这些让方案显得专业的细节。

4.1 断网缓存与断线重连:当网络恢复时,本地数据如何无缝续传

断网不是异常,是常态。本方案设计“双缓冲”机制:

  • 内存缓冲:ConcurrentQueue<SensorData>存网断期间新数据,容量上限 10000 条(约 2.8 小时数据),满则覆盖最老数据;
  • 磁盘缓冲:SQLite 中offline_cache表存待上传数据,带upload_status字段(Pending,Uploading,Success,Failed)。
// OfflineCacheService.cs:缓存与续传 public void CacheData(SensorData data) { // 先存内存队列 _memoryCache.Enqueue(data); // 再存磁盘,带重试标记 _sqliteService.Execute( "INSERT INTO offline_cache (device_id, raw_value, converted_value, unit, created_at, upload_status) " + "VALUES (@deviceId, @raw, @conv, @unit, @time, 'Pending')", new { data.DeviceId, data.RawValue, data.ConvertedValue, data.Unit, data.CreatedAt }); } // 后台任务:每 30 秒检查网络,尝试上传 private async Task UploadPendingData() { if (!IsNetworkAvailable()) return; var pending = _sqliteService.Query<OfflineCacheItem>( "SELECT * FROM offline_cache WHERE upload_status = 'Pending' LIMIT 100"); foreach (var item in pending) { try { await _cloudService.UploadDataAsync(item); // 假设云服务接口 _sqliteService.Execute( "UPDATE offline_cache SET upload_status = 'Success' WHERE id = @id", new { item.Id }); } catch (Exception ex) { Log.Warn($"上传失败 {item.Id}: {ex.Message}"); // 失败不删除,下次重试 } } }

设计说明:LIMIT 100是关键。一次性传太多可能超时,分批传更稳。失败记录保留在库中,下次网络恢复继续传,直到成功或人工干预。upload_status字段支持运维查“哪些数据没传上去”。

4.2 多设备统一管理:如何让 20 台设备不变成 20 个配置文件

设备管理页不是简单表格。它支持:

  • 批量导入:Excel 模板含device_id,model,address,baud_rate,modbus_function,range_min,range_max,一键导入并生成device_config记录;
  • 拓扑视图:用TreeView显示物理连接(如 “RS485-A 总线 → 设备 0x01(温湿度)→ 设备 0x02(光照)”),右键可“测试通信”;
  • 固件升级:选中设备,点“升级”,自动下载 bin 文件,按 Modbus 协议分块写入 Flash(需设备支持)。
// DeviceImportService.cs:Excel 导入核心 public async Task ImportFromExcel(string filePath) { using var stream = File.OpenRead(filePath); using var package = new ExcelPackage(stream); var worksheet = package.Workbook.Worksheets[0]; var devices = new List<DeviceConfig>(); for (int row = 2; row <= worksheet.Dimension.End.Row; row++) // 跳过标题行 { devices.Add(new DeviceConfig { DeviceId = worksheet.Cells[row, 1].Text, Model = worksheet.Cells[row, 2].Text, Address = Convert.ToByte(worksheet.Cells[row, 3].Value?.ToString()), BaudRate = Convert.ToInt32(worksheet.Cells[row, 4].Value), ModbusFunction = worksheet.Cells[row, 5].Text, RangeMin = Convert.ToDouble(worksheet.Cells[row, 6].Value), RangeMax = Convert.ToDouble(worksheet.Cells[row, 7].Value), LastOnlineTime = DateTime.UtcNow }); } await _deviceService.BatchInsert(devices); // 批量插入,事务保障 }

参数说明:Excel 模板提供.xlsx下载链接(资源包内含),列顺序严格对应,避免学生填错列。BatchInsert用事务包裹,100 台设备要么全成功,要么全失败,不出现“一半导入一半失败”的脏数据。

4.3 报表导出:不只是 Excel,而是带水印、签名、设备元数据的交付物

导出按钮不是DataGridView.ExportToExcel()。本方案用 EPPlus 库生成专业报表:

  • 封面页:含报告名称、生成时间、操作人(从 Windows 登录用户取)、系统版本;
  • 数据页:表格含设备编号、型号、采集时间、温度(℃)、湿度(%RH)、光照(lux)、EC(mS/cm),每列有筛选器;
  • 水印页:PDF 导出时,在每页加半透明“内部资料 严禁外传”水印;
  • 签名区:Excel 最后一行留空,供打印后手写签名、盖章。
// ReportExporter.cs:生成 Excel 报表 public async Task<byte[]> ExportToExcel(DateTime from, DateTime to) { using var package = new ExcelPackage(); var sheet = package.Workbook.Worksheets.Add("温室监控数据"); // 写入表头 var headers = new[] { "设备编号", "型号", "采集时间", "温度(℃)", "湿度(%RH)", "光照(lux)", "EC(mS/cm)" }; for (int i = 0; i < headers.Length; i++) sheet.Cells[1, i + 1].Value = headers[i]; // 查询数据(带设备信息 JOIN) var sql = @" SELECT d.device_id, d.model, s.created_at, s.converted_value, s.unit FROM sensor_data s JOIN device_config d ON s.device_id = d.device_id WHERE s.created_at BETWEEN @from AND @to ORDER BY s.created_at"; var data = _sqliteService.Query<dynamic>(sql, new { from, to }); // 填充数据(按单位分列) int row = 2; foreach (var item in data) { sheet.Cells[row, 1].Value = item.device_id; sheet.Cells[row, 2].Value = item.model; sheet.Cells[row, 3].Value = item.created_at; switch (item.unit) { case "℃": sheet.Cells[row, 4].Value = item.converted_value; break; case "%RH": sheet.Cells[row, 5].Value = item.converted_value; break; case "lux": sheet.Cells[row, 6].Value = item.converted_value; break; case "mS/cm": sheet.Cells[row, 7].Value = item.converted_value; break; } row++; } // 自动列宽 sheet.Cells.AutoFitColumns(); // 添加水印(仅 PDF 导出时用,此处略) return package.GetAsByteArray(); }

设计说明:dynamic查询避免为每种单位建不同实体类,用switch分发到对应列,逻辑清晰。AutoFitColumns()确保导出后不用手动调列宽,提升交付体验。

4.4 权限控制:不是“登录密码”,而是角色驱动的功能开关

小系统也需权限。本方案设三角色:

角色可访问页面可执行操作
Operator(操作员)主监控页、历史曲线查看、导出报表、确认报警
Technician(技术员)设备管理、报警规则修改设备参数、调整报警阈值、重启串口
Admin(管理员)全部页面新增用户、分配角色、查看操作日志

权限检查在菜单项Click事件中:

// MainForm.cs:菜单权限控制 private void mnuDeviceManagement_Click(object sender, EventArgs e) { if (!_authService.HasPermission(CurrentUser, Permission.ManageDevices)) { MessageBox.Show("权限不足,无法访问设备管理", "访问拒绝", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } ShowDeviceManagementForm(); }

注意:权限不存 Session,而存于CurrentUser对象(登录时从数据库查出),每次操作前检查。HasPermission方法查user_role和role_permission关联表,支持后期扩展。

避坑 / 常见问题 / 排查 / 注意

4.5 工程化落地四大后悔药

现象 1:导出 Excel 时程序假死,进度条不动
原因:EPPlus 写入大数据量时在 UI 线程同步执行,阻塞消息循环。
解决:用Task.Run(() => ExportToExcel(...))移到后台线程,导出完成后再Invoke回 UI 线程保存文件。

现象 2:多用户同时操作设备管理页,修改冲突
原因:两人同时编辑同一设备,后保存者覆盖前保存者的修改。
解决:设备编辑窗体加载时读取last_updated时间戳,保存前校验数据库中该时间戳未变;若已变,提示“数据已被他人修改,请刷新后重试”。

现象 3:Windows 10/11 上串口 COM 名称不一致(如 COM3 vs COM4)
原因:USB 转串口芯片驱动安装顺序不同,系统分配 COM 号随机。
解决:不硬编码 COM 名,用SerialPort.GetPortNames()获取当前可用端口,界面下拉框选择,并记住用户上次选择(存注册表或 config.json)。

现象 4:程序最小化后,串口接收停止
原因:DataReceived事件在 UI 线程触发,最小化时某些系统策略限制后台线程活动。
解决:DataReceived事件处理器中,用BeginInvoke将数据

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询