大数跨境

.NET 8 + Cursor 编写的工业 Web SCADA 系统

.NET 8 + Cursor 编写的工业 Web SCADA 系统 dotNET跨平台
2026-07-05
3
导读:前言工业现场的数据采集与监控,说起来简单,做起来坑不少。串口超时、寄存器地址错位、设备突然离线、数据刷新太快

前言

工业现场的数据采集与监控,说起来简单,做起来坑不少。串口超时、寄存器地址错位、设备突然离线、数据刷新太快页面卡死、历史数据堆积成山——每个问题都能让人折腾好几天。

这两年在折腾工业监控系统时,一直想搞一套既能快速落地、又不用被商业组态软件绑架的方案。.NET 8 的 Blazor Server 给了我一个新思路:用 C# 从头撸到尾,从设备驱动到前端展示,不用换语言,不用搞前后端分离那一套复杂工程。把思路和实现整理出来,希望能给同样在搞工业监控的同行一些参考。

项目介绍

一套基于 .NET 8 + Blazor Server 开发的轻量级工业 Web SCADA 系统,专门针对多泵站集中监控场景设计。整套系统涵盖了设备数据采集、实时监控、报警管理、历史存储和数据分析等核心环节。

系统默认配置了一套四泵站标准点表模板(71 个标签/站),涵盖液位、频率、运行状态、控制模式等关键参数。开箱即用,只要配好 Modbus TCP 设备地址,就能在浏览器里看到泵站的实时运转情况。

项目功能

实时监控

  • 泵站总览 Dashboard:在线/离线统计、报警数量、各站液位概览

  • 泵站详情页:液位实时曲线、泵组启停控制、运行状态指示灯

  • 标签数据 100ms 级刷新,操作响应基本感觉不到延迟

报警管理

  • 高低限报警、设备离线报警自动触发

  • 报警列表支持按时间、级别、确认状态筛选

  • 报警确认操作带操作人记录

历史数据

  • 标签值定时归档(默认 10 秒一次)

  • 支持按时间范围查询任意标签的历史曲线

  • 数据导出为 CSV 格式

设备诊断

  • 每个设备的通讯状态实时可见

  • 提供 /api/status、/api/device-status 等诊断接口

  • 采集日志结构化输出,排查问题不靠猜

多客户端推送

  • SignalR 实时广播,外部客户端可订阅数据

  • 防抖机制避免高频推送压垮网络

项目特点

**全栈 C#**:从 Modbus 驱动到 Web 界面,一套技术栈从头写到尾,维护成本低,团队上手快

配置驱动:设备和点表全部走 JSON 配置,新增泵站只需复制一份模板文件改改 IP 地址

轻量存储:SQLite 作为历史库,部署省心,数据量不大时完全够用

采集与展示分离:后台服务负责轮询和计算,Blazor 组件只做渲染,各司其职

防抖推送:不管是页面更新还是 SignalR 广播,都做了 100ms 批量合并,避免高频刷新把浏览器搞崩

并行轮询:多设备并行采集,一个设备卡住不影响其他站的数据更新

项目技术

层级
技术选型
说明
前端框架
Blazor Server (InteractiveServer)
服务端渲染,实时数据通过 SignalR 推送
通讯协议
Modbus TCP (NModbus)
主流 PLC 和 RTU 都支持,兼容性好
实时推送
SignalR
WebSocket 长连接,延迟低
数据存储
EF Core + SQLite
轻量、免安装,适合工控机部署
日志
Serilog
结构化日志,方便接入 ELK 或 Seq
图表
ECharts (CDN)
液位趋势、历史曲线展示
DI 容器
.NET 8 原生 DI
不用第三方容器,够用

项目分层结构

IndustrialWebScada/
├── config/                         # 配置目录
│   ├── devices.json                # 设备列表
│   └── device-templates/           # 点表模板
│       └── pump-station.json       # 71 标签标准模板
├── src/
│   ├── IndustrialWebScada.Core/    # 领域模型、接口、事件定义
│   ├── IndustrialWebScada.Application/  # 业务逻辑:TagValueStore、AlarmEngine
│   ├── IndustrialWebScada.Drivers/      # ModbusTcp、MQTT 驱动实现
│   ├── IndustrialWebScada.Infrastructure/ # EF Core + SQLite 持久化
│   ├── IndustrialWebScada.BackgroundServices/ # 采集、推送、报警、归档服务
│   └── IndustrialWebScada.Web/     # Blazor Server 主程序
│       ├── Components/Pages/       # Dashboard、PumpStationList 等页面
│       ├── Components/Shared/      # PumpStationCard、WaterLevelTank 等组件
│       └── Program.cs              # 入口与 DI 配置
└── tests/
    └── IndustrialWebScada.Drivers.Tests/ # 驱动层单元测试

项目代码

数据采集服务(核心循环)

public classDataCollectionService : BackgroundService
{
    privatereadonly ITagValueStore _store;
    privatereadonly IEnumerable<IDeviceDriver> _drivers;
    privatereadonly ILogger<DataCollectionService> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var tasks = _drivers.Select(d => CollectDeviceAsync(d, stoppingToken));
            await Task.WhenAll(tasks);  // 并行采集,互不干扰
            
            await Task.Delay(GetInterval(), stoppingToken);
        }
    }

    private async Task CollectDeviceAsync(IDeviceDriver driver, CancellationToken ct)
    {
        try
        {
            var tags = await driver.ReadAllTagsAsync(ct);
            _store.Update(tags, DateTime.UtcNow);
        }
        catch (Exception ex)
        {
            _logger.Warning(ex, "采集失败 [{DeviceId}]", driver.DeviceId);
            _store.MarkOffline(driver.DeviceId);
        }
    }
}

采集服务以 BackgroundService 形式跑在后台,每个设备独立并行执行。某个设备超时或断线不会拖累其它设备的数据更新。

防抖推送器(100ms 批量合并)

public classTagValueNotifier : IDisposable
{
    privatereadonly System.Timers.Timer _timer;
    privatereadonly Dictionary<string, TagValue> _buffer = new();
    privatereadonlyobject _lock = new();
    privatereadonlyint _intervalMs = 100;

    publicevent Action<IReadOnlyList<TagValue>> OnNotified;

    public void Push(TagValue tagValue)
    {
        lock (_lock)
        {
            _buffer[tagValue.TagId] = tagValue;  // 同一标签只保留最新值
        }
    }

    private void Flush()
    {
        List<TagValue> snapshot;
        lock (_lock)
        {
            if (_buffer.Count == 0return;
            snapshot = _buffer.Values.ToList();
            _buffer.Clear();
        }
        OnNotified?.Invoke(snapshot);
    }
}

页面组件订阅这个 Notifier,收到批量数据后统一调用 StateHasChanged()。100ms 的窗口既能保证实时性,又避免了逐条刷新带来的频繁渲染开销。

Modbus 地址合并读取(减少报文往返)

public async Task<Dictionary<stringobject>> ReadAllTagsAsync(CancellationToken ct)
{
    var result = new Dictionary<stringobject>();
    var mergedRequests = MergeAdjacentAddresses(_tagConfigs);

    foreach (var request in mergedRequests)
    {
        var data = request.FunctionCode switch
        {
            2 => await _modbusMaster.ReadInputsAsync(unitId, request.Start, request.Length),
            3 => await _modbusMaster.ReadHoldingRegistersAsync(unitId, request.Start, request.Length),
            4 => await _modbusMaster.ReadInputRegistersAsync(unitId, request.Start, request.Length),
            _ => thrownew NotSupportedException()
        };
        // 按原始点表拆分数据
        ParseAndFill(result, data, request);
    }
    return result;
}

点表里 71 个标签,如果逐个读取需要发 71 次请求。这个合并逻辑会把地址连续的寄存器批量读取,读 16 个保持寄存器只需 1 次请求,效率直接拉满。

项目效果

主控台页面聚合了所有泵站的状态概览,右上角显示在线/离线数量和未确认报警数,中间区域以卡片形式展示各站的液位和运行泵数。

泵站详情页是使用频率最高的界面。左侧是液位动态水位图,中间是四台泵的运行状态灯和启停按钮,底部是最近 30 分钟的液位趋势。控制指令下发后,泵状态的变化在 1-2 秒内就能在界面上反映出来。

报警页面按时间倒序排列,未确认的报警高亮显示。点击确认按钮会记录操作人和确认时间,方便事后追溯。

历史查询页面支持选择时间范围和标签名称,曲线图用 ECharts 渲染,缩放和平移操作都很流畅。右侧提供 CSV 导出按钮,方便导出数据做进一步分析。

项目源码

GitHub:https://github.com/suwencjp/VibeCodingIndustrialWebScada

总结

回过头来看,这套系统最大的价值在于把工业采集和 Web 监控打通了,而且全程没离开 .NET 生态。当然,这套方案也有明显的局限。Blazor Server 依赖长连接,大规模部署时对服务器的 SignalR 连接数有压力。另外 SQLite 在高并发写入场景下性能一般,如果点位超过 5000 个,建议换成 PostgreSQL 或 InfluxDB。不过对于中小型泵站(几十到几百个标签)来说,这套架构足够皮实好用了。

如果你也在做类似的工业监控项目,欢迎交流,也欢迎给项目提 issue 或 PR。 

【声明】内容源于网络
0
0
dotNET跨平台
专注于.NET Core的技术传播。在这里你可以谈微软.NET,Mono的跨平台开发技术。在这里可以让你的.NET项目有新的思路,不局限于微软的技术栈,横跨Windows,
内容 2302
粉丝 0
dotNET跨平台 专注于.NET Core的技术传播。在这里你可以谈微软.NET,Mono的跨平台开发技术。在这里可以让你的.NET项目有新的思路,不局限于微软的技术栈,横跨Windows,
总阅读52.2k
粉丝0
内容2.3k