接手一个新的工控项目,甲方第一句话往往是:"能不能先给我看个 Demo?"
这句话背后的潜台词是:我不想在一个看不见摸不着的方案上押注,我要看到真实的东西跑起来。
这个需求合理,但对开发者来说压力不小。采集、告警、追溯——这三个模块单独拿出来都不简单,凑在一起还要在一周内跑通,很多人第一反应是"时间不够"。
但其实,MVP(最小可用版本)的核心不是功能完整,而是流程跑通。采集能读到数据,告警能触发提示,追溯能查到历史——这三件事做到,Demo 就成立了。
读完本文,你将掌握:
全文约 3800 字,代码可直接运行,建议收藏对照项目使用。
做 Demo 最常见的死法,是在动手之前把架构设计得过于完美。微服务、消息队列、分布式存储……这些东西在生产环境有价值,但在 Demo 阶段是纯粹的负担。
我在项目中见过一个团队,花了两周讨论技术选型,结果 Demo 演示日到了,界面还没有。过度设计是 Demo 的头号杀手。
采集、告警、追溯三个模块存在依赖关系——告警依赖采集的数据,追溯依赖告警和采集的记录。如果按顺序开发,后两个模块永远在等前一个模块"完善"。
正确的做法是:先定义好模块间的数据契约(接口和数据结构),然后用 Mock 数据让各模块独立开发、独立调试,最后再接真实数据。
很多人一上来就想用 SQL Server 或 MySQL,结果光环境配置就花掉半天。Demo 阶段用 SQLite 完全够用——文件型数据库,零安装,NuGet 一个包搞定,部署时直接把 .db 文件带走。
咱们这个 Demo 系统的数据流是这样的:
定时采集 → 数据缓冲队列 → 告警规则引擎 → 告警状态机 ↓ ↓ SQLite 采集记录 SQLite 告警记录 ↓ ↓ 追溯查询界面
采集层负责定时读取设备数据(Demo 阶段用随机数模拟),推入一个线程安全的队列。告警层消费队列数据,对照规则表判断是否触发告警,并维护每条告警的状态(触发 → 确认 → 消除)。追溯层是纯查询,从 SQLite 里按时间段、按设备、按告警类型检索历史记录。
三层之间只通过数据模型和接口交互,互不依赖实现细节,这样才能并行开发。
先把贯穿全系统的核心数据结构定义清楚:
csharp/// <summary>
/// 采集数据点:一次采集的最小单元
/// </summary>
public class DataPoint
{
public int Id { get; set; }
public string DeviceId { get; set; } // 设备编号
public string TagName { get; set; } // 测点名称,如 "Temperature"
public double Value { get; set; } // 采集值
public string Unit { get; set; } // 单位,如 "℃"
public DateTime Timestamp { get; set; } // 采集时间
public bool IsValid { get; set; } // 数据质量标志
}
/// <summary>
/// 告警记录:一条告警的完整生命周期
/// </summary>
public class AlarmRecord
{
public int Id { get; set; }
public string DeviceId { get; set; }
public string TagName { get; set; }
public string AlarmType { get; set; } // "HighHigh" / "High" / "Low" / "LowLow"
public double TriggerValue { get; set; } // 触发时的实际值
public double ThresholdValue { get; set; } // 对应的阈值
public DateTime TriggeredAt { get; set; }
public DateTime? AckedAt { get; set; } // 确认时间,null 表示未确认
public DateTime? ClearedAt { get; set; } // 消除时间,null 表示未消除
public AlarmState State { get; set; }
}
public enum AlarmState
{
Active, // 活跃:已触发,未确认
Acked, // 已确认:操作员知晓,但条件未消除
Cleared // 已消除:触发条件不再满足
}
csharpusing System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text;
namespace AppWpf202611
{
/// <summary>
/// 采集服务:定时轮询设备数据,推入处理队列
/// Demo 阶段使用随机数模拟,生产环境替换 ReadFromDevice 方法即可
/// </summary>
public class CollectionService : IDisposable
{
private readonly ConcurrentQueue<DataPoint> _dataQueue;
private readonly IDataRepository _repository;
private readonly List<DeviceConfig> _devices;
private Timer? _timer;
private readonly Random _rng = new Random();
public event Action<DataPoint>? DataCollected;
// 采集间隔:Demo 阶段设 1 秒,生产环境按需调整
private const int INTERVAL_MS = 1000;
public CollectionService(
ConcurrentQueue<DataPoint> dataQueue,
IDataRepository repository,
List<DeviceConfig> devices)
{
_dataQueue = dataQueue;
_repository = repository;
_devices = devices;
}
public void Start()
{
_timer = new Timer(OnTick, null, 0, INTERVAL_MS);
}
private void OnTick(object state)
{
foreach (var device in _devices)
{
var point = ReadFromDevice(device);
if (point == null) continue;
// 推入队列供告警模块消费
_dataQueue.Enqueue(point);
DataCollected?.Invoke(point);
// 异步持久化,不阻塞采集线程
Task.Run(() => _repository.SaveDataPointAsync(point));
}
}
/// <summary>
/// 模拟采集:生产环境替换为 Modbus/OPC-UA/串口读取
/// </summary>
private DataPoint ReadFromDevice(DeviceConfig device)
{
try
{
// 模拟温度传感器:基准值 ± 随机波动
double value = device.BaseValue + (_rng.NextDouble() - 0.5) * device.Fluctuation;
// 随机制造异常波动,提高告警触发概率
if (_rng.NextDouble() < 0.20)
{
if (device.TagName == "Temperature")
{
value += 12 + _rng.NextDouble() * 8;
}
else if (device.TagName == "Pressure")
{
value -= 20 + _rng.NextDouble() * 20;
}
}
return new DataPoint
{
DeviceId = device.DeviceId,
TagName = device.TagName,
Value = Math.Round(value, 2),
Unit = device.Unit,
Timestamp = DateTime.Now,
IsValid = true
};
}
catch (Exception)
{
// 采集失败:记录无效数据点,保持时序完整性
return new DataPoint
{
DeviceId = device.DeviceId,
TagName = device.TagName,
Value = 0,
Timestamp = DateTime.Now,
IsValid = false
};
}
}
public void Stop() => _timer?.Change(Timeout.Infinite, Timeout.Infinite);
public void Dispose() => _timer?.Dispose();
}
/// <summary>
/// 设备配置:Demo 阶段硬编码,生产环境从配置文件或数据库读取
/// </summary>
public class DeviceConfig
{
public string DeviceId { get; set; }
public string TagName { get; set; }
public string Unit { get; set; }
public double BaseValue { get; set; } // 模拟基准值
public double Fluctuation { get; set; } // 模拟波动范围
}
}
ConcurrentQueue 而不是普通 Queue采集线程和告警处理线程是两个独立的执行上下文,普通 Queue 在多线程读写时需要手动加锁,稍不注意就死锁或数据错乱。ConcurrentQueue<T> 是 .NET 内置的无锁并发队列,生产者(采集)和消费者(告警)可以同时操作,不需要任何额外的同步代码。
告警模块是整个系统里逻辑最密集的部分。它需要做两件事:判断是否触发(规则引擎),以及管理告警的生命周期(状态机)。
csharp/// <summary>
/// 告警规则:每条规则对应一个测点的一个告警条件
/// </summary>
public class AlarmRule
{
public string DeviceId { get; set; }
public string TagName { get; set; }
public string AlarmType { get; set; }
public double Threshold { get; set; } // 触发阈值
public double Deadband { get; set; } // 回差,防止值在阈值附近反复抖动触发
public bool IsHighAlarm { get; set; } // true=超上限告警,false=低于下限告警
/// <summary>
/// 判断当前值是否触发告警
/// </summary>
public bool IsTriggered(double value)
=> IsHighAlarm ? value >= Threshold : value <= Threshold;
/// <summary>
/// 判断告警是否已消除(含回差)
/// </summary>
public bool IsCleared(double value)
=> IsHighAlarm
? value < Threshold - Deadband
: value > Threshold + Deadband;
}
csharp/// <summary>
/// 告警处理服务:消费采集队列,对照规则表管理告警状态
/// </summary>
public class AlarmService
{
private readonly ConcurrentQueue<DataPoint> _dataQueue;
private readonly IAlarmRepository _repository;
private readonly List<AlarmRule> _rules;
// 活跃告警字典:Key = "DeviceId_TagName_AlarmType"
private readonly ConcurrentDictionary<string, AlarmRecord> _activeAlarms
= new ConcurrentDictionary<string, AlarmRecord>();
// 告警变化事件,供 ViewModel 订阅刷新界面
public event Action<AlarmRecord> AlarmTriggered;
public event Action<AlarmRecord> AlarmCleared;
private CancellationTokenSource _cts;
public AlarmService(
ConcurrentQueue<DataPoint> dataQueue,
IAlarmRepository repository,
List<AlarmRule> rules)
{
_dataQueue = dataQueue;
_repository = repository;
_rules = rules;
}
public void Start()
{
_cts = new CancellationTokenSource();
Task.Run(() => ProcessLoop(_cts.Token));
}
private async Task ProcessLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// 批量消费队列,减少循环空转
while (_dataQueue.TryDequeue(out var point))
{
await ProcessDataPoint(point);
}
await Task.Delay(200, ct); // 200ms 轮询一次队列
}
}
private async Task ProcessDataPoint(DataPoint point)
{
if (!point.IsValid) return;
// 找出所有匹配该测点的规则
var matchedRules = _rules.Where(r =>
r.DeviceId == point.DeviceId &&
r.TagName == point.TagName);
foreach (var rule in matchedRules)
{
var key = $"{rule.DeviceId}_{rule.TagName}_{rule.AlarmType}";
if (rule.IsTriggered(point.Value))
{
// 告警触发:若该告警尚未活跃,则新建记录
if (!_activeAlarms.ContainsKey(key))
{
var alarm = new AlarmRecord
{
DeviceId = rule.DeviceId,
TagName = rule.TagName,
AlarmType = rule.AlarmType,
TriggerValue = point.Value,
ThresholdValue = rule.Threshold,
TriggeredAt = DateTime.Now,
State = AlarmState.Active
};
_activeAlarms[key] = alarm;
await _repository.SaveAlarmAsync(alarm);
// 通知 UI 层
AlarmTriggered?.Invoke(alarm);
}
}
else if (rule.IsCleared(point.Value))
{
// 告警消除:更新状态并从活跃字典移除
if (_activeAlarms.TryRemove(key, out var alarm))
{
alarm.ClearedAt = DateTime.Now;
alarm.State = AlarmState.Cleared;
await _repository.UpdateAlarmAsync(alarm);
AlarmCleared?.Invoke(alarm);
}
}
}
}
/// <summary>
/// 操作员确认告警
/// </summary>
public async Task AcknowledgeAlarmAsync(int alarmId)
{
var key = _activeAlarms.Keys.FirstOrDefault(k =>
_activeAlarms[k].Id == alarmId);
if (key != null && _activeAlarms.TryGetValue(key, out var alarm))
{
alarm.AckedAt = DateTime.Now;
alarm.State = AlarmState.Acked;
await _repository.UpdateAlarmAsync(alarm);
}
}
public void Stop() => _cts?.Cancel();
}
回差(Deadband)是告警系统里非常容易被忽视的细节。 如果没有回差,当测点值在阈值附近轻微抖动时,告警会在一秒内反复触发和消除,产生大量无效记录,操作员根本没法看。加了回差之后,触发阈值是 80℃,消除阈值就变成 78℃(回差 2℃),抖动问题自然消失。
csharpusing System;
using System.Collections.Generic;
using System.Text;
using Dapper;
using Microsoft.Data.Sqlite;
namespace AppWpf202611
{
/// <summary>
/// SQLite 数据仓储:采集记录与告警记录的统一存储
/// 依赖:Microsoft.Data.Sqlite(NuGet 安装)
/// </summary>
public class SqliteRepository : IDataRepository, IAlarmRepository
{
private readonly string _connectionString;
public SqliteRepository(string dbPath)
{
_connectionString = $"Data Source={dbPath}";
InitializeDatabase();
}
private void InitializeDatabase()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
// 创建采集记录表
conn.Execute(@"
CREATE TABLE IF NOT EXISTS DataPoints (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
DeviceId TEXT NOT NULL,
TagName TEXT NOT NULL,
Value REAL NOT NULL,
Unit TEXT,
Timestamp TEXT NOT NULL,
IsValid INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_dp_device_time
ON DataPoints(DeviceId, Timestamp);
");
// 创建告警记录表
conn.Execute(@"
CREATE TABLE IF NOT EXISTS AlarmRecords (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
DeviceId TEXT NOT NULL,
TagName TEXT NOT NULL,
AlarmType TEXT NOT NULL,
TriggerValue REAL NOT NULL,
ThresholdValue REAL NOT NULL,
TriggeredAt TEXT NOT NULL,
AckedAt TEXT,
ClearedAt TEXT,
State INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_ar_device_time
ON AlarmRecords(DeviceId, TriggeredAt);
");
}
/// <summary>
/// 保存采集数据点
/// </summary>
public async Task SaveDataPointAsync(DataPoint point)
{
using var conn = new SqliteConnection(_connectionString);
await conn.ExecuteAsync(@"
INSERT INTO DataPoints
(DeviceId, TagName, Value, Unit, Timestamp, IsValid)
VALUES
(@DeviceId, @TagName, @Value, @Unit, @Timestamp, @IsValid)",
point);
}
/// <summary>
/// 追溯查询:按设备、测点、时间范围检索采集历史
/// </summary>
public async Task<IEnumerable<DataPoint>> QueryDataPointsAsync(
string deviceId,
string tagName,
DateTime from,
DateTime to,
int maxRows = 1000)
{
using var conn = new SqliteConnection(_connectionString);
return await conn.QueryAsync<DataPoint>(@"
SELECT * FROM DataPoints
WHERE DeviceId = @DeviceId
AND TagName = @TagName
AND Timestamp BETWEEN @From AND @To
AND IsValid = 1
ORDER BY Timestamp DESC
LIMIT @MaxRows",
new
{
DeviceId = deviceId,
TagName = tagName,
From = from,
To = to,
MaxRows = maxRows
});
}
/// <summary>
/// 追溯查询:按设备和时间范围检索告警历史
/// </summary>
public async Task<IEnumerable<AlarmRecord>> QueryAlarmsAsync(
string deviceId,
DateTime from,
DateTime to)
{
using var conn = new SqliteConnection(_connectionString);
return await conn.QueryAsync<AlarmRecord>(@"
SELECT * FROM AlarmRecords
WHERE DeviceId = @DeviceId
AND TriggeredAt BETWEEN @From AND @To
ORDER BY TriggeredAt DESC",
new { DeviceId = deviceId, From = from, To = to });
}
public async Task SaveAlarmAsync(AlarmRecord alarm)
{
using var conn = new SqliteConnection(_connectionString);
alarm.Id = await conn.ExecuteScalarAsync<int>(@"
INSERT INTO AlarmRecords
(DeviceId, TagName, AlarmType, TriggerValue, ThresholdValue,
TriggeredAt, State)
VALUES
(@DeviceId, @TagName, @AlarmType, @TriggerValue, @ThresholdValue,
@TriggeredAt, @State);
SELECT last_insert_rowid();",
alarm);
}
public async Task UpdateAlarmAsync(AlarmRecord alarm)
{
using var conn = new SqliteConnection(_connectionString);
await conn.ExecuteAsync(@"
UPDATE AlarmRecords
SET AckedAt = @AckedAt,
ClearedAt = @ClearedAt,
State = @State
WHERE Id = @Id",
alarm);
}
}
}
依赖说明: 上述代码使用了
Dapper(轻量 ORM)和Microsoft.Data.Sqlite,NuGet 安装命令:
<PackageReference Include="Dapper" Version="2.1.66" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.4" />
csharpusing System.Collections.Concurrent;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Windows.Input;
namespace AppWpf202611;
public class MainViewModel : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
private void Notify(string n) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
private readonly CollectionService _collector;
private readonly AlarmService _alarmService;
private readonly SqliteRepository _repository;
private readonly Dictionary<string, DataPoint> _liveDataIndex = new();
private readonly RelayCommand _startCommand;
private readonly AsyncRelayCommand _queryTraceCommand;
private readonly AsyncRelayCommand _ackAlarmCommand;
private bool _isRunning;
private int _totalCollected;
private string _statusText = "系统待启动";
private string _selectedTraceDevice = "DEV-01";
private string _selectedTraceTag = "Temperature";
private DateTime _traceFrom = DateTime.Now.AddHours(-1);
private DateTime _traceTo = DateTime.Now;
private AlarmRecord? _selectedAlarm;
public ObservableCollection<DataPoint> LiveData { get; } = new();
public ObservableCollection<AlarmRecord> ActiveAlarms { get; } = new();
public ObservableCollection<DataPoint> TraceResult { get; } = new();
public ObservableCollection<string> DeviceIds { get; } = new();
public ObservableCollection<string> TagNames { get; } = new();
public ICommand StartCommand => _startCommand;
public ICommand QueryTraceCommand => _queryTraceCommand;
public ICommand AckAlarmCommand => _ackAlarmCommand;
public int TotalCollected
{
get => _totalCollected;
set
{
if (_totalCollected == value) return;
_totalCollected = value;
Notify(nameof(TotalCollected));
}
}
public int ActiveAlarmCount => ActiveAlarms.Count;
public string StatusText
{
get => _statusText;
set
{
if (_statusText == value) return;
_statusText = value;
Notify(nameof(StatusText));
}
}
public string SelectedTraceDevice
{
get => _selectedTraceDevice;
set
{
if (_selectedTraceDevice == value) return;
_selectedTraceDevice = value;
Notify(nameof(SelectedTraceDevice));
_queryTraceCommand.RaiseCanExecuteChanged();
}
}
public string SelectedTraceTag
{
get => _selectedTraceTag;
set
{
if (_selectedTraceTag == value) return;
_selectedTraceTag = value;
Notify(nameof(SelectedTraceTag));
_queryTraceCommand.RaiseCanExecuteChanged();
}
}
public DateTime TraceFrom
{
get => _traceFrom;
set
{
if (_traceFrom == value) return;
_traceFrom = value;
Notify(nameof(TraceFrom));
}
}
public DateTime TraceTo
{
get => _traceTo;
set
{
if (_traceTo == value) return;
_traceTo = value;
Notify(nameof(TraceTo));
}
}
public AlarmRecord? SelectedAlarm
{
get => _selectedAlarm;
set
{
_selectedAlarm = value;
Notify(nameof(SelectedAlarm));
_ackAlarmCommand.RaiseCanExecuteChanged();
}
}
public MainViewModel()
{
var queue = new ConcurrentQueue<DataPoint>();
_repository = new SqliteRepository("demo.db");
var devices = new List<DeviceConfig>
{
new() { DeviceId = "DEV-01", TagName = "Temperature", Unit = "℃", BaseValue = 75, Fluctuation = 10 },
new() { DeviceId = "DEV-01", TagName = "Pressure", Unit = "kPa", BaseValue = 200, Fluctuation = 30 },
new() { DeviceId = "DEV-02", TagName = "Temperature", Unit = "℃", BaseValue = 60, Fluctuation = 8 }
};
var rules = new List<AlarmRule>
{
new() { DeviceId = "DEV-01", TagName = "Temperature", AlarmType = "High", Threshold = 80, Deadband = 2, IsHighAlarm = true },
new() { DeviceId = "DEV-01", TagName = "Temperature", AlarmType = "HighHigh", Threshold = 90, Deadband = 2, IsHighAlarm = true },
new() { DeviceId = "DEV-01", TagName = "Pressure", AlarmType = "Low", Threshold = 170, Deadband = 5, IsHighAlarm = false }
};
foreach (var deviceId in devices.Select(d => d.DeviceId).Distinct()) DeviceIds.Add(deviceId);
foreach (var tagName in devices.Select(d => d.TagName).Distinct()) TagNames.Add(tagName);
_collector = new CollectionService(queue, _repository, devices);
_alarmService = new AlarmService(queue, _repository, rules);
_collector.DataCollected += point =>
App.Current.Dispatcher.Invoke(() => UpdateLiveData(point));
_alarmService.AlarmTriggered += alarm =>
App.Current.Dispatcher.Invoke(() =>
{
ActiveAlarms.Insert(0, alarm);
Notify(nameof(ActiveAlarmCount));
});
_alarmService.AlarmCleared += alarm =>
App.Current.Dispatcher.Invoke(() =>
{
var existing = ActiveAlarms.FirstOrDefault(a => a.Id == alarm.Id);
if (existing != null)
{
ActiveAlarms.Remove(existing);
Notify(nameof(ActiveAlarmCount));
}
});
_startCommand = new RelayCommand(_ => StartSystem(), _ => !_isRunning);
_queryTraceCommand = new AsyncRelayCommand(_ => QueryTraceAsync(), _ => !string.IsNullOrWhiteSpace(SelectedTraceDevice) && !string.IsNullOrWhiteSpace(SelectedTraceTag));
_ackAlarmCommand = new AsyncRelayCommand(_ => AckSelectedAlarmAsync(), _ => SelectedAlarm is { State: AlarmState.Active });
}
private void UpdateLiveData(DataPoint point)
{
var key = $"{point.DeviceId}:{point.TagName}";
if (_liveDataIndex.TryGetValue(key, out var existing))
{
existing.Value = point.Value;
existing.Timestamp = point.Timestamp;
existing.IsValid = point.IsValid;
}
else
{
_liveDataIndex[key] = point;
LiveData.Add(point);
}
TotalCollected++;
StatusText = $"采集中... 最新刷新 {DateTime.Now:HH:mm:ss}";
}
public void StartSystem()
{
if (_isRunning)
{
return;
}
_collector.Start();
_alarmService.Start();
_isRunning = true;
_startCommand.RaiseCanExecuteChanged();
StatusText = "系统已启动";
}
private async Task QueryTraceAsync()
{
var from = TraceFrom;
var to = TraceTo;
if (from > to)
{
(from, to) = (to, from);
}
var results = await _repository.QueryDataPointsAsync(SelectedTraceDevice, SelectedTraceTag, from, to);
TraceResult.Clear();
foreach (var p in results)
{
TraceResult.Add(p);
}
StatusText = $"追溯查询完成,共 {TraceResult.Count} 条";
}
private async Task AckSelectedAlarmAsync()
{
if (SelectedAlarm is not { State: AlarmState.Active } alarm)
{
return;
}
await _alarmService.AcknowledgeAlarmAsync(alarm.Id);
alarm.AckedAt = DateTime.Now;
alarm.State = AlarmState.Acked;
_ackAlarmCommand.RaiseCanExecuteChanged();
StatusText = $"告警已确认:{alarm.DeviceId}-{alarm.TagName}-{alarm.AlarmType}";
}
}


坑一:UI 线程更新集合忘了 Dispatcher
AlarmService 的事件是在后台线程触发的,直接操作 ObservableCollection 会抛出跨线程异常。所有 UI 集合的修改必须通过 App.Current.Dispatcher.Invoke() 回到主线程,这是 WPF 开发的基本规则,但在赶进度时特别容易忘。
坑二:SQLite 写入频率过高导致锁竞争
采集间隔 1 秒、3 个测点,每秒写入 3 条记录,SQLite 单连接完全扛得住。但如果采集间隔缩短到 100ms 或测点数量超过 50 个,建议改用批量写入策略:先把数据攒在内存里,每 5 秒批量 INSERT 一次,写入吞吐量可以提升 10 倍以上。
坑三:告警规则表写死在代码里
Demo 阶段图省事把规则硬编码没问题,但演示时甲方一定会说"我想改一下这个阈值"。建议从第一天就把规则存到 SQLite 的 AlarmRules 表里,启动时读取,支持运行时热更新,这个改动只需要半小时,却能让 Demo 显得专业很多。
坑四:时间戳存储格式不统一
SQLite 没有原生的 DateTime 类型,存成字符串时格式必须统一为 yyyy-MM-dd HH:mm:ss.fff,否则按时间范围查询时排序结果会乱掉。用 Dapper 时建议注册全局的 DateTime 类型处理器,一次配置全局生效。
| 时间 | 任务 | 产出 |
|---|---|---|
| Day 1 | 搭项目结构、定数据模型、建 SQLite 表 | 可运行的空壳项目 |
| Day 2 | 实现采集服务 + Mock 数据 | 数据能写入 SQLite |
| Day 3 | 实现告警规则引擎 + 状态机 | 告警能触发和消除 |
| Day 4 | 搭主界面 ViewModel + 事件绑定 | 数据能显示在界面 |
| Day 5 | 实现追溯查询界面 + 时间选择器 | 历史数据可查询 |
| Day 6 | 联调三个模块,修复边界 Bug | 流程完整跑通 |
| Day 7 | UI 美化、加载动画、异常提示 | 可对外演示的 Demo |
测试环境:Windows 10,.NET 6,WPF,Visual Studio 2022,3 个虚拟测点,采集间隔 1 秒,持续运行 1 小时,SQLite 文件大小约 2.1MB,查询响应时间 < 50ms。
ConcurrentQueue + 事件驱动,是解耦采集、告警、UI 三层的最轻量方式,不需要引入任何消息框架。一周搭出来的 Demo,离生产环境还有相当距离——真实的设备通信协议(Modbus、OPC-UA)、多用户权限管理、报表导出、远程监控……这些都是后续要补的功课。
但 Demo 的价值恰恰在于:它让你和甲方站在同一个真实的系统面前对话,而不是对着一份 PPT 各自想象。流程跑通之后,每一个后续的功能迭代都有了落脚点,开发节奏也会越来越稳。
本文介绍的采集 + 告警 + 追溯三模块架构,已在实际项目的 Demo 阶段验证可用,代码结构清晰,可直接作为脚手架使用。
**你在做工控 Demo 时,遇到过哪些让你印象深刻的技术坑?欢迎在评论区分享,看看大家踩过的
相关信息
我用夸克网盘给你分享了「AppWpf202611.zip」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/53a53YrKNy:/
链接:https://pan.quark.cn/s/e0996c091fad
提取码:UchB


本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!