Dashboard 设计KPI卡片与液位可视化这是「从零搭建灌装监控系统」系列第16篇。前面十五篇把通信、数据、报警、日志都做完了但操作员每天盯着的是首页。这篇把后端数据翻译成一眼能看懂的东西四张 KPI 卡片、一个液位柱、一个会呼吸的阀门指示灯。数据都对但没人看得懂功能联调的时候我在首页上放了一排 Label把设备状态原样显示出来产量 102、温度 25.6、运行时间 4.2、节拍 1.5。车间主任看了两分钟问我一句话“所以现在是正常的吗”那一刻我意识到问题不在数据在表达。设备状态本身是中性的操作员需要的是对比——产量对不对得上目标、温度偏离设定值多少、节拍比标准快了还是慢了、阀门到底开没开。纯数字把判断责任推给了人。首页的设计目标就变成了让操作员用一眼代替计算。四张卡片回答四个问题首页顶部排四张卡片每张回答一个问题卡片主数值对照值回答的问题当前产量ActualCountTargetCount今天做了多少离目标还差多少实时温度CurrentTempSettingTemp温度稳不稳偏没偏运行时间RunningTimeDeviceStatus设备在跑还是停了当前节拍CurrentCycleTimeStandardCycleTime快了还是慢了布局用四列等宽的Grid每张卡片套同一个CardStyleGridGrid.Row0Margin0,0,20,0Grid.ColumnDefinitionsColumnDefinitionWidth*/ColumnDefinitionWidth*/ColumnDefinitionWidth*/ColumnDefinitionWidth*//Grid.ColumnDefinitionsBorderGrid.Column0Style{StaticResource CardStyle}Margin0,0,10,0StackPanelTextBlockText当前产量PCS)Foreground{StaticResource AppTextMuted}FontSize14/TextBlockText{Binding ActualCount,StringFormat{}{0:N0}}Foreground{StaticResource AppAccentColor}FontSize32FontFamily{StaticResource FontMono}/TextBlockForeground{StaticResource AppSuccess}FontSize12RunText↑ 目标达成率/RunText{Binding ActualCount}/RunText//RunText{Binding TargetCount}//TextBlock/StackPanel/Border!-- 另外三张同构 --/Grid两个细节值得说。主数值用等宽字体FontMono。数值每秒都在跳等宽字体让数字宽度固定不会出现整行左右抖动。这个体验差异在小屏上特别明显。对照值不换行、不抢焦点。主数值 32 号对照值 12 号且用弱化色。人的视线先落在主数值需要细节时再往下看一行。液位可视化一个矩形就够了液位这种东西用数字表达最浪费。操作员看“液位 63%”要在大脑里转一道看一根柱子不用。Grid!-- 柱体容器 --BorderBackground#1A1A1ACornerRadius4/!-- 液面高度直接绑 LiquidLevel --Rectanglex:NameLiquidLevelVerticalAlignmentBottomOpacity0.8Height{Binding LiquidLevel}Rectangle.FillLinearGradientBrushStartPoint1,0EndPoint0,0GradientStopColor#007ACCOffset0/GradientStopColor#00AAFFOffset1//LinearGradientBrush/Rectangle.Fill/Rectangle!-- 底部底座 --RectangleHeight10VerticalAlignmentBottomMargin0,0,0,20Fill#555//GridVerticalAlignmentBottom加上绑定高度就是一根从底部涨上来的柱子。水平方向的渐变StartPoint1,0到EndPoint0,0让柱体有圆柱的反光感比纯色好看不少。这里有一个容易踩的点Height绑定的是像素值不是百分比。LiquidLevel在 ViewModel 里必须已经换算成实际像素高度。如果直接把 0-100 的百分比丢给Height液位 63 就只画 63 像素高永远到不了顶。换算放在 ViewModel 里或者用IMultiValueConverter把百分比和容器高度一起算。我选前者因为容器高度在界面里是已知的算好再给 UI 更简单。阀门状态让灯会呼吸“阀门打开”和“阀门关闭”只差一个词操作员扫一眼很难捕捉。加一个脉冲动画视觉上就有了优先级。用ControlTemplate的DataTrigger实现不需要任何后台代码ContentControlHorizontalAlignmentRightContentControl.TemplateControlTemplateTargetTypeContentControlStackPanelOrientationHorizontalx:NameContainerEllipsex:NameValueEllipseWidth10Height10Fill{StaticResource AppTextMuted}Margin0,0,5,0/TextBlockx:NameValueTextText阀门关闭Foreground{StaticResource AppTextMuted}FontSize12//StackPanelControlTemplate.TriggersDataTriggerBinding{Binding ValueOpen}ValueTrueSetterTargetNameValueEllipsePropertyFillValue{StaticResource AppSuccess}/SetterTargetNameValueTextPropertyTextValue阀门打开/SetterTargetNameValueTextPropertyForegroundValue{StaticResource AppSuccess}/DataTrigger.EnterActionsBeginStoryboardx:NamePulseStoryboardRepeatBehaviorForeverAutoReverseTrueDoubleAnimationStoryboard.TargetNameValueEllipseStoryboard.TargetPropertyOpacityFrom1.0To0.3Duration0:0:1//Storyboard/BeginStoryboard/DataTrigger.EnterActions/DataTrigger/ControlTemplate.Triggers/ControlTemplate/ContentControl.Template/ContentControlRepeatBehaviorForeverAutoReverseTrue就是一秒一次、来回渐隐。动画只改Opacity不触发布局开销可以忽略。数据流如下DataReceivedAlarmTriggeredPlcService 轮询DashboardViewModel四张 KPI 卡片温度曲线液位柱阀门指示灯AlarmService最近报警列表最近报警只留十条首页放一个最近报警列表让操作员不用切页面就知道刚才发生了什么。关键是限长privatevoidAlarmService_AlarmTriggered(object?sender,AlarmRecorde){Application.Current.Dispatcher.Invoke((){RecentAlarms.Insert(0,AlarmUIModel.FromAlarmRecord(e));if(RecentAlarms.Count10)RecentAlarms.RemoveAt(RecentAlarms.Count-1);});}插入到头部超长从尾部删。这样列表永远是“最近十条”不会随着运行时间无限膨胀。断线之后不要显示旧数据这是我最看重的一条通信断了首页必须清零而不是冻结在最后一帧。privatevoidOnConnectionChanged(object?sender,boolconnected){if(!connected){Application.Current.Dispatcher.Invoke((){ActualCount0;TargetCount0;CurrentTemp0;SettingTemp0;RunningTime0;CurrentCycleTime0;StandardCycleTime0;LiquidLevel0;ValueOpenfalse;});}}为什么要清零因为冻结的旧数据比空白更危险。操作员看到一个正常的温度和液位会以为设备在跑实际上通信早就断了。显示 0 很刺眼刺眼才会让人去看报警。工业界面上“看起来正常”是最贵的一种错误。踩坑记录在构造函数里直接建图表集合SeriesCollection属于 UI 对象必须在 UI 线程创建。构造函数不保证在 UI 线程执行所以要用Dispatcher.BeginInvoke包起来。这个问题我在第 17 篇展开讲。液位高度直接绑百分比Height要像素值。绑 0-100 的百分比柱子永远涨不满。换算放在 ViewModel。断线后保留旧值看起来“界面没崩”实际是在误导操作员。断线即清零。报警列表不设上限跑一整天就是几千条ObservableCollection越大每次插入的开销越明显。插头部 限长。本篇小结设计点做法KPI 卡片四列等宽 Grid 统一 CardStyle主数值可读性等宽字体 大字号 弱化对照值液位Rectangle 底部对齐 高度绑像素值 水平渐变状态指示DataTrigger Storyboard 脉冲动画最近报警插头部 限长十条断线处理全部指标清零不用旧数据糊弄首页能看了但操作员还会问一个更细的问题温度是稳住了还是在慢慢爬数字回答不了这个得靠曲线。下期预告第17篇LiveCharts 实时温度曲线下一篇把温度做成滚动曲线重点解决两个问题怎么只保留最近一段数据以及为什么图表集合不能在后台线程创建。