
工欲善其事必先利其器!
传统Winform上位机程序主要重心都是倾向于和业务系统通信和PLC下位机的交互上,这个无论是站在项目进度上看还是在项目交付时间上都是没有问题的。但是从客户体验感的角度看相比WPF或者Avalonia开发的界面就过于捉襟见肘了.尤其是对于大型企业,仓库摆满了各种各样的竞争对手设备,参观的人络绎不绝,各种效率,精度,体验对比之下,路过咱们设备的时候,看到东倒西歪的控件按钮是什么感觉O(∩_∩)O

- 交互原理
上图文是实际上位机和下位机通过原始TCP长连接通信交互,上位机作为客户端,分帧方式是按完整JSON object的大括号边界分帧;不使用换行符或长度头。应答机制为当前无通用ACK/NACK。目标分配成功不应答;指定拒绝场景返回异常类型。
除了下发格口是由上位机发给下位机,其余生成包裹,包裹落格,包裹分拣完成等等均由下位机服务端上发
(TCP允许拆包和粘包,连续发送{...}{...},或把一个JSON对象拆成多次发送,下位机均按照对象边界处理)
- 功能模块
本文以原始TCP长连接方式和下位机通信,运行流程如下:------>监听下位机包裹流水号生成
------>匹配相机解码
------>和客户WCS/WMS系统交互
------>本地格口映射
------>通知下位机分拣规则格口
------>下位机反馈包裹落格动作
------>下位机反馈包裹分拣完成 。
以上是完整的运作流程。辅助功能模块包括:分拣计划配置(用于格口映射),历史数据查询,格口分布报表,每小时分拣效率报表等等
- 源码共享
系统功能细节,篇幅有限暂不做过多介绍,本文主要分享上位机UI界面动画效果以及实现原理和源码分享。

- Label外边框标识
实际项目浅绿色外边框标识控件表示已经分配了目标格口的包裹,红色边框标识的控件表示尚未映射格口或者分拣到异常的包裹效果,具体实现方法如下:
lbl.Paint += (sender, e) =>
{
Label lb = sender as Label;
if (lb != null && lb.Tag is Color color)
{
ControlPaint.DrawBorder(e.Graphics, lb.ClientRectangle,
color, 2, ButtonBorderStyle.Solid,
color, 2, ButtonBorderStyle.Solid,
color, 2, ButtonBorderStyle.Solid,
color, 2, ButtonBorderStyle.Solid);
}
};
如果需要动态规划label外边框颜色,可以在生成label控件的时候将动态规划颜色存储到Tag属性中,方便后续动态规划边框颜色特效
- DataGridView表格美化
废话不多说,直接上源码(效果如顶部主图)
#region 表格美化
// 1. 启用双缓冲,彻底解决绘制闪烁问题
typeof(DataGridView).InvokeMember("DoubleBuffered",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.SetProperty,
null, dataGridView1, new object[] { true });
// 2. 订阅核心事件
dataGridView1.CellMouseEnter += DataGridView1_CellMouseEnter;
dataGridView1.CellMouseLeave += DataGridView1_CellMouseLeave;
dataGridView1.MouseLeave += DataGridView1_MouseLeave;
dataGridView1.RowPostPaint += DataGridView1_RowPostPaint;
dataGridView1.Scroll += (s, e) => { if (_hoverRowIndex >= 0) dataGridView1.Invalidate(); };
// 3. 当数据源变动(如刷新、排序、加载)时,重置边框到第一行
dataGridView1.DataSourceChanged += (s, e) => { _hoverRowIndex = 0; dataGridView1.Invalidate(); };
dataGridView1.RowsAdded += (s, e) => { if (_hoverRowIndex == -1) { _hoverRowIndex = 0; dataGridView1.Invalidate(); } };
// 初始化 DataGridView 数据源
_gridTable = new DataTable();
_gridTable.Columns.Add("扫码时间", typeof(string));
_gridTable.Columns.Add("条码", typeof(string));
_gridTable.Columns.Add("包裹ID", typeof(long));
_gridTable.Columns.Add("长度", typeof(decimal));
_gridTable.Columns.Add("宽度", typeof(decimal));
_gridTable.Columns.Add("高度", typeof(decimal));
_gridTable.Columns.Add("重量", typeof(decimal));
_gridTable.Columns.Add("目标格口", typeof(int));
_gridTable.Columns.Add("实际格口", typeof(int));
_gridTable.Columns.Add("状态", typeof(string));
dataGridView1.DataSource = _gridTable;
dataGridView1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None;
// 设置各列的 FillWeight(权重越大列越宽)
dataGridView1.Columns["扫码时间"].FillWeight = 1.8f;
dataGridView1.Columns["条码"].FillWeight = 2.0f; // 加宽
dataGridView1.Columns["包裹ID"].FillWeight = 1.8f; // 加宽
dataGridView1.Columns["长度"].FillWeight = 0.6f; // 缩短
dataGridView1.Columns["宽度"].FillWeight = 0.6f; // 缩短
dataGridView1.Columns["高度"].FillWeight = 0.6f; // 缩短
dataGridView1.Columns["重量"].FillWeight = 0.6f; // 缩短
dataGridView1.Columns["目标格口"].FillWeight = 0.8f;
dataGridView1.Columns["实际格口"].FillWeight = 0.8f;
dataGridView1.Columns["状态"].FillWeight = 1f;
// 美化
dataGridView1.RowHeadersVisible = false; // 去掉行头空白列
dataGridView1.SelectionMode = DataGridViewSelectionMode.FullRowSelect;
dataGridView1.ReadOnly = true;
dataGridView1.AllowUserToAddRows = false;
dataGridView1.BackgroundColor = Color.FromArgb(3, 11, 23);
dataGridView1.ForeColor = Color.FromArgb(126, 181, 199);
dataGridView1.Font = new Font("宋体", 9F);
// 列头样式(背景色 0,51,119,字体不加粗,字体颜色 Cyan)
dataGridView1.EnableHeadersVisualStyles = false; // 必须
dataGridView1.ColumnHeadersDefaultCellStyle.Font = new Font("宋体", 10F, FontStyle.Regular);
dataGridView1.ColumnHeadersDefaultCellStyle.BackColor = Color.FromArgb(0, 51, 119);
dataGridView1.ColumnHeadersDefaultCellStyle.ForeColor = Color.Cyan;
// 数据行样式
dataGridView1.DefaultCellStyle.BackColor = Color.FromArgb(3, 11, 23);
dataGridView1.DefaultCellStyle.ForeColor = Color.Cyan;
dataGridView1.AlternatingRowsDefaultCellStyle.BackColor = Color.FromArgb(20, 30, 50);
dataGridView1.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill;
dataGridView1.RowTemplate.Height = 30;
dataGridView1.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter;
dataGridView1.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter; // 水平垂直居中
dataGridView1.AlternatingRowsDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter;
// 列头高度与行高一致,去除边框
dataGridView1.ColumnHeadersHeight = dataGridView1.RowTemplate.Height + 20;
dataGridView1.ColumnHeadersBorderStyle = DataGridViewHeaderBorderStyle.None;
dataGridView1.CellBorderStyle = DataGridViewCellBorderStyle.None; // 完全无边框
dataGridView1.AllowUserToOrderColumns = false;
foreach (DataGridViewColumn col in dataGridView1.Columns)
{
col.SortMode = DataGridViewColumnSortMode.NotSortable;
}
dataGridView1.RowsDefaultCellStyle.SelectionBackColor = Color.FromArgb(3, 11, 23);
dataGridView1.RowsDefaultCellStyle.SelectionForeColor = Color.Cyan;
dataGridView1.AlternatingRowsDefaultCellStyle.SelectionBackColor = Color.FromArgb(20, 30, 50);
dataGridView1.AlternatingRowsDefaultCellStyle.SelectionForeColor = Color.Cyan;
// 订阅选中变化以重绘边框
dataGridView1.SelectionChanged += (s, e) => dataGridView1.Invalidate();
// 默认选中第一行
if (dataGridView1.Rows.Count > 0)
dataGridView1.Rows[0].Selected = true;
dataGridView1.DataError += (s, ev) =>
{
ShellLine.WriteLine($"DataGridView DataError: Row={ev.RowIndex}, Col={ev.ColumnIndex}, Msg={ev.Exception?.Message}");
ev.ThrowException = false;
};
#endregion
绘制鼠标悬浮外边框核心源码如下:
// 核心绘制方法:在行绘制完成后,额外绘制一个矩形边框
private void DataGridView1_RowPostPaint(object sender, DataGridViewRowPostPaintEventArgs e)
{
// 第一行永远显示边框(最新记录)
bool isFirstRow = (e.RowIndex == 0);
// 当前行被选中时显示边框
bool isSelected = dataGridView1.Rows[e.RowIndex].Selected;
// 鼠标悬浮的行显示边框
bool isHover = (_hoverRowIndex == e.RowIndex);
// 只要满足任一条件即绘制
if (!isFirstRow && !isSelected && !isHover)
return;
if (!dataGridView1.Rows[e.RowIndex].Visible)
return;
Rectangle rect = e.RowBounds;
rect.X += 1;
rect.Width -= 2;
rect.Y += 1;
rect.Height -= 2;
using (Pen pen = new Pen(Color.FromArgb(0, 160, 230), 2.5f))
{
e.Graphics.DrawRectangle(pen, rect);
}
}

- 控件轨迹移动特效以及落格动画效果
private void AnimationTimer_Tick(object sender, EventArgs e)
{
if (_isMinimized) return; // 最小化时不更新
List<Package> completedPackages = new List<Package>();
lock (packageLock)
{
foreach (var pkg in packages)
{
int step = (int)(pkg.SpeedPxPerSec / 50.0);
if (step < 1) step = 1;
if (pkg.Phase == MovePhase.Horizontal)
{
if (pkg.Label.Left < pkg.HorizontalTarget.X)
{
int oldLeft = pkg.Label.Left;
pkg.Label.Left += step;
if (pkg.Label.Left > pkg.HorizontalTarget.X)
pkg.Label.Left = pkg.HorizontalTarget.X;
}
if (pkg.Label.Left >= pkg.HorizontalTarget.X && pkg.DropTriggered)
{
pkg.Phase = MovePhase.Falling;
double dx = pkg.FallTarget.X - pkg.Label.Left;
double dy = pkg.FallTarget.Y - pkg.Label.Top;
double distance = Math.Sqrt(dx * dx + dy * dy);
double speed = distance / FallDurationSec;
speed = Math.Max(10, Math.Min(500, speed));
pkg.SpeedPxPerSec = speed;
ShellLine.WriteLine($"包裹{pkg.Id} 开始下落,速度={speed:F1}px/s");
}
}
else if (pkg.Phase == MovePhase.Falling)
{
// 下落逻辑(原样保持不变)
int dx = pkg.FallTarget.X - pkg.Label.Left;
int dy = pkg.FallTarget.Y - pkg.Label.Top;
int fallStep = step;
if (fallStep < 1) fallStep = 1;
if (Math.Abs(dx) <= fallStep && Math.Abs(dy) <= fallStep)
{
pkg.Label.Left = pkg.FallTarget.X;
pkg.Label.Top = pkg.FallTarget.Y;
pkg.Phase = MovePhase.Completed;
CompletePackage(pkg);
completedPackages.Add(pkg);
}
else
{
double distance = Math.Sqrt(dx * dx + dy * dy);
double ratio = fallStep / distance;
int moveX = (int)(dx * ratio);
int moveY = (int)(dy * ratio);
if (moveX == 0 && moveY == 0)
{
moveX = Math.Sign(dx);
moveY = Math.Sign(dy);
}
pkg.Label.Left += moveX;
pkg.Label.Top += moveY;
}
}
}
// 移除已完成的包裹
foreach (var pkg in completedPackages)
packages.Remove(pkg);
}
}
包裹和格口控件重合后高亮显示以及控件资源释放:
private void CompletePackage(Package pkg)
{
// 包裹消失并释放控件
pkg.Label.Visible = false;
this.Controls.Remove(pkg.Label);
pkg.Label.Dispose();
// 目标格口变色(亮绿色)
Label chute = pkg.TargetChute;
chute.BackColor = Color.LimeGreen;
// 延迟恢复颜色(0.5秒)
// 若该格口已有恢复定时器,先取消
if (chuteRestoreTimers.ContainsKey(chute))
{
chuteRestoreTimers[chute].Stop();
chuteRestoreTimers[chute].Dispose();
chuteRestoreTimers.Remove(chute);
}
System.Windows.Forms.Timer restoreTimer = new System.Windows.Forms.Timer();
restoreTimer.Interval = 500;
restoreTimer.Tag = chute;
restoreTimer.Tick += RestoreChuteColor_Tick;
restoreTimer.Start();
chuteRestoreTimers[chute] = restoreTimer;
// 累加重量到目标格口
int idx = chuteLabels.IndexOf(pkg.TargetChute);
if (idx >= 0 && idx < _chuteTotalWeights.Length)
{
_chuteTotalWeights[idx] += pkg.Weight;
decimal kg = _chuteTotalWeights[idx] / 1000m;
chuteLabels[idx].Text = idx == 24 ? $"异常口\n{kg:F1} kg" : $"格口{idx + 1}\n{kg:F1} kg";
}
_parcelPackageMap.TryRemove(pkg.Id, out _);//从映射字典中移除,释放引用
// 更新日志(安全调用)
UpdateStatus($"分拣成功:{pkg.Id} → {chute.Name}", Color.Green);
}
private void RestoreChuteColor_Tick(object sender, EventArgs e)
{
System.Windows.Forms.Timer timer = sender as System.Windows.Forms.Timer;
timer.Stop();
Label chute = timer.Tag as Label;
if (chute != null)
{
int idx = chuteLabels.IndexOf(chute);
if (idx >= 0)
chute.BackColor = chuteOriginalColors[idx]; // 恢复原始颜色
if (chuteRestoreTimers.ContainsKey(chute))
chuteRestoreTimers.Remove(chute);
}
timer.Dispose();
}
- 最后
如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享只是的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。
公众号会经常发一些实用的小工具和demo源码,有兴趣的麻烦点个关注,赠人玫瑰,手留余香,感谢您的关注,期待和您一起探讨!再会!
