
简介这是一套基于 WPF 的 WMS 仓库管理系统示例源码面向具备一定 C# 与 XAML 基础、希望深入理解 MVVM 落地方式的桌面开发学习者。项目采用 Stylet 框架组织 MVVM 结构配合 MaterialDesign 实现界面样式数据访问层使用 SqlSugar 操作 SqlServer2008涵盖登录界面、主界面、左侧下拉菜单、DataGrid 查询展示等典型模块适合作为企业级后台管理系统的入门参考。压缩包共 498 个文件约 17.8MB其中 140 个 cs 源码文件承载业务与视图模型逻辑13 个 xaml 与 13 个 baml 对应界面布局另有 67 个 dll、20 个 json 及若干 csproj、sln 等工程与依赖文件结构完整可直接编译运行。目前已有 1072 人学习下载。通过该源码可掌握 Stylet 的依赖注入与窗口管理、MaterialDesign 控件定制、SqlSugar 增删改查封装以及登录与主界面导航的完整链路便于快速搭建自己的 WMS 或后台管理项目骨架。1. 从登录窗到主界面WPF MVVM 在 WMS 仓库系统里到底怎么落地很多做 WMS 仓库管理系统的团队一开始都会掉进同一个坑登录窗用事件驱动写主界面用 Code-Behind 堆控件等到要加权限、要换皮肤、要做多仓库切换时整个项目已经改不动了。WPF 的 MVVM 不是学院派玩具它在仓库系统这种「表单密集、权限复杂、状态多」的场景里恰恰是最省心的架构选择。这篇笔记就围绕「登录 → 主界面」这条最短路径把 WPF MVVM 在 WMS 里的工程化做法讲透ViewModel 怎么组织、命令怎么绑定、登录态怎么传递、主界面的导航和权限怎么收口。适合正在用 WPF 做仓储、ERP、MES 这类桌面端的开发者也适合从 WinForm 迁过来、被数据绑定和命令绕晕的同学。下面所有代码都是可复现的最小骨架不依赖任何第三方 UI 库也能跑起来。2. 登录模块的 MVVM 拆解从密码框绑定到异步校验登录看起来简单但在 WMS 里它承担了三件事身份认证、仓库/账套选择、登录态初始化。这三件事如果全塞进一个LoginWindow.xaml.cs后面加个「记住上次仓库」都要改窗口代码。MVVM 的做法是把它们拆成 View纯 XAML、ViewModel状态与命令、Model/Service认证与配置读写。2.1 为什么密码框不能直接双向绑定WPF 的PasswordBox.Password属性出于安全考虑不是依赖属性所以{Binding Password}这种写法在密码框上直接失效。新手最常见的翻车就是在这里绑定了但永远拿不到值调试半天以为是 DataContext 没设对。常见做法有三种我一般用「附加属性 命令参数」的组合。先定义一个附加属性把密码同步到 ViewModel// PasswordBoxHelper.cs using System.Windows; using System.Windows.Controls; public static class PasswordBoxHelper { // 附加属性把 PasswordBox 的密码同步到 ViewModel 的绑定源 public static readonly DependencyProperty BoundPasswordProperty DependencyProperty.RegisterAttached( BoundPassword, typeof(string), typeof(PasswordBoxHelper), new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnBoundPasswordChanged)); public static string GetBoundPassword(DependencyObject obj) (string)obj.GetValue(BoundPasswordProperty); public static void SetBoundPassword(DependencyObject obj, string value) obj.SetValue(BoundPasswordProperty, value); private static void OnBoundPasswordChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is not PasswordBox box) return; // 防止回写造成死循环 box.PasswordChanged - OnPasswordChanged; if (box.Password ! (string)e.NewValue) box.Password (string)e.NewValue; box.PasswordChanged OnPasswordChanged; } private static void OnPasswordChanged(object sender, RoutedEventArgs e) { var box (PasswordBox)sender; SetBoundPassword(box, box.Password); } }逻辑说明附加属性BoundPassword让 XAML 里可以写local:PasswordBoxHelper.BoundPassword{Binding Password, ModeTwoWay}。OnBoundPasswordChanged里先解绑事件再赋值是为了避免「ViewModel 改密码 → 触发 PasswordChanged → 又回写 ViewModel」这种循环。参数上BindsTwoWayByDefault保证默认双向省得每个绑定都写 Mode。XAML 侧这样用PasswordBox local:PasswordBoxHelper.BoundPassword{Binding Password, ModeTwoWay} Height32 Margin0,8 /提示不要为了省事把密码明文存进 ViewModel 的普通属性然后序列化到配置文件WMS 的登录信息一旦落盘就是安全事故。2.2 LoginViewModel 的状态与命令设计登录 ViewModel 要暴露的核心成员其实就几个用户名、密码、仓库列表、选中的仓库、登录命令、是否正在登录。命令用ICommand实现推荐自己写一个轻量RelayCommand比引第三方库更可控。// RelayCommand.cs using System; using System.Windows.Input; public class RelayCommand : ICommand { private readonly Actionobject? _execute; private readonly Predicateobject?? _canExecute; public RelayCommand(Actionobject? execute, Predicateobject?? canExecute null) { _execute execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute canExecute; } // CanExecuteChanged 挂到 CommandManager 上按钮可用状态自动刷新 public event EventHandler? CanExecuteChanged { add CommandManager.RequerySuggested value; remove CommandManager.RequerySuggested - value; } public bool CanExecute(object? parameter) _canExecute?.Invoke(parameter) ?? true; public void Execute(object? parameter) _execute(parameter); }逻辑说明把CanExecuteChanged委托给CommandManager.RequerySuggested是 WPF 里最省事的做法——只要界面交互触发重询按钮的禁用/启用就会自动更新不用手动RaiseCanExecuteChanged。参数execute用object?是为了兼容带参命令比如列表行里的「删除」按钮。LoginViewModel 骨架public class LoginViewModel : INotifyPropertyChanged { private readonly IAuthService _authService; private string _userName string.Empty; private string _password string.Empty; private bool _isBusy; public string UserName { get _userName; set { _userName value; OnPropertyChanged(); } } public string Password { get _password; set { _password value; OnPropertyChanged(); } } public bool IsBusy { get _isBusy; set { _isBusy value; OnPropertyChanged(); } } public ObservableCollectionWarehouse Warehouses { get; } new(); public Warehouse? SelectedWarehouse { get; set; } public ICommand LoginCommand { get; } public LoginViewModel(IAuthService authService) { _authService authService; LoginCommand new RelayCommand(async _ await LoginAsync(), _ !IsBusy !string.IsNullOrWhiteSpace(UserName)); } private async Task LoginAsync() { IsBusy true; try { // 认证 拉取该账号可访问的仓库列表 var result await _authService.AuthenticateAsync(UserName, Password, SelectedWarehouse?.Id); if (result.Success) LoginCompleted?.Invoke(this, result.Session); else ErrorMessage result.Message; } finally { IsBusy false; } } public event EventHandlerUserSession? LoginCompleted; public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged([CallerMemberName] string? name null) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }逻辑说明LoginCommand的canExecute里判断!IsBusy登录过程中按钮自动禁用避免重复提交——这在仓库系统里很关键重复登录可能触发多次会话初始化。LoginCompleted事件把UserSession抛给 View由 View 决定是打开主窗口还是提示错误ViewModel 不直接new MainWindow()保持可测试。参数说明IAuthService是接口真实项目里注入具体实现UserSession里至少带UserId、UserName、WarehouseId、Permissions集合后面主界面的权限控制全靠它。2.3 登录成功后的窗口切换与登录态传递登录窗关闭、主窗打开这个动作放在 View 层做最干净。常见做法是在App.xaml.cs里控制启动流程而不是在登录窗里直接Show主窗。// App.xaml.cs 片段 protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var loginVm new LoginViewModel(new AuthService()); var loginWin new LoginWindow { DataContext loginVm }; loginVm.LoginCompleted (_, session) { var mainVm new MainViewModel(session, new NavigationService()); var mainWin new MainWindow { DataContext mainVm }; mainWin.Show(); loginWin.Close(); // 先 Show 再 Close避免应用因窗口数为 0 而退出 }; loginWin.Show(); }逻辑说明ShutdownMode默认是OnLastWindowClose如果先Close登录窗再Show主窗中间会有一瞬间窗口数为 0应用直接退出——这是很多人遇到的「登录成功后程序莫名关闭」的元凶。先Show主窗再Close登录窗就绕开了。参数上session通过构造函数注入 MainViewModel比全局静态变量更利于单元测试。3. 主界面布局与导航把菜单、内容区、状态栏拆成可组合的 ViewModel主界面是 WMS 的门面左侧菜单、顶部工具栏、中间内容区、底部状态栏四块区域各自独立又互相联动。MVVM 的难点不在布局而在「菜单点击后内容区怎么换」「权限怎么控制菜单可见性」「状态栏怎么实时反映当前仓库」。3.1 用 ContentControl DataTemplate 做内容区导航不要在主界面里塞一堆TabControl或者手动Visibility切换那样每加一个模块就要改主界面 XAML。正确姿势是内容区放一个ContentControl绑定到MainViewModel.CurrentView再用DataTemplate把 ViewModel 类型映射到 View。!-- MainWindow.xaml 内容区 -- ContentControl Content{Binding CurrentView} Grid.Column1 Margin8 /!-- App.xaml 资源里注册映射 -- Application.Resources DataTemplate DataType{x:Type vm:InboundViewModel} v:InboundView / /DataTemplate DataTemplate DataType{x:Type vm:OutboundViewModel} v:OutboundView / /DataTemplate DataTemplate DataType{x:Type vm:StockQueryViewModel} v:StockQueryView / /DataTemplate /Application.Resources逻辑说明ContentControl.Content拿到的是 ViewModel 对象WPF 根据运行时类型去资源里找匹配的DataTemplate找到就渲染对应 View。这样主界面完全不知道有哪些业务模块新增模块只需加一个 DataTemplate 和一条菜单项。参数上DataType必须是具体 ViewModel 类型不能写基类否则所有模块都会渲染成同一个 View。3.2 菜单项与权限MenuItem 的 ItemsSource 绑定WMS 的菜单必须按角色动态生成仓管员看不到财务模块管理员能看到全部。做法是把菜单抽象成MenuNode集合权限过滤在 ViewModel 里完成。public class MenuNode { public string Title { get; set; } string.Empty; public string Icon { get; set; } string.Empty; public Type? ViewModelType { get; set; } // 叶子节点才有 public ListMenuNode Children { get; set; } new(); public string? RequiredPermission { get; set; } }// MainViewModel 里构建菜单 private void BuildMenus(UserSession session) { var all new ListMenuNode { new() { Title 入库管理, RequiredPermission Inbound.View, Children { new() { Title 收货登记, ViewModelType typeof(InboundViewModel), RequiredPermission Inbound.Receive } } }, new() { Title 出库管理, RequiredPermission Outbound.View, Children { new() { Title 拣货发运, ViewModelType typeof(OutboundViewModel), RequiredPermission Outbound.Pick } } }, new() { Title 库存查询, ViewModelType typeof(StockQueryViewModel), RequiredPermission Stock.Query } }; // 递归过滤没有权限的节点直接剔除 Menus new ObservableCollectionMenuNode(Filter(all, session.Permissions)); } private static IEnumerableMenuNode Filter(IEnumerableMenuNode nodes, HashSetstring perms) { foreach (var n in nodes) { if (n.RequiredPermission ! null !perms.Contains(n.RequiredPermission)) continue; n.Children Filter(n.Children, perms).ToList(); yield return n; } }逻辑说明Filter用递归 yield return父节点没权限时子节点自然不出现父节点有权限但子节点全被过滤时父节点会变成空菜单——实际项目里可以再加一条「Children 为空且无 ViewModelType 则剔除」的规则。参数RequiredPermission用字符串常量建议集中在一个静态类里定义避免拼写错误。XAML 侧用HierarchicalDataTemplate渲染TreeView ItemsSource{Binding Menus} TreeView.Resources HierarchicalDataTemplate DataType{x:Type vm:MenuNode} ItemsSource{Binding Children} TextBlock Text{Binding Title} Padding4 / /HierarchicalDataTemplate /TreeView.Resources TreeView.ItemContainerStyle Style TargetTypeTreeViewItem Setter PropertyIsExpanded ValueTrue / /Style /TreeView.ItemContainerStyle /TreeView3.3 状态栏与当前仓库的联动状态栏要显示当前仓库、当前用户、在线状态。这些数据都来自UserSession但仓库可能在主界面里被切换所以MainViewModel要暴露一个CurrentWarehouse属性状态栏绑定它。private Warehouse _currentWarehouse; public Warehouse CurrentWarehouse { get _currentWarehouse; set { if (_currentWarehouse value) return; _currentWarehouse value; OnPropertyChanged(); // 切换仓库后通知所有子模块刷新数据 EventAggregator.Publish(new WarehouseChangedEvent(value.Id)); } }逻辑说明切换仓库时通过事件聚合器广播各业务 ViewModel 订阅后重新加载数据。这里不直接调用子模块方法是为了避免 MainViewModel 依赖所有子模块保持单向依赖。参数WarehouseChangedEvent里带WarehouseId订阅方按需过滤。4. 避坑与排查WPF MVVM 在 WMS 里最容易翻车的 5 个点这一章全是血泪经验每条都按「现象 → 原因 → 解决」写遇到对应症状直接对号入座。现象一登录按钮点了没反应命令不执行。原因通常是DataContext没设对或者RelayCommand的CanExecute一直返回 false。排查时先在Execute里打断点如果断点不进检查 XAML 根节点有没有DataContext或者按钮的Command绑定路径是否写错。另一个隐蔽原因是CanExecute里访问了未初始化的集合导致异常被吞掉WPF 会静默把按钮置灰。解决CanExecute里只做轻量判断异常用 try-catch 包住并打日志。现象二主界面内容区切换后旧 View 不释放内存持续上涨。原因是DataTemplate生成的 View 被 WPF 缓存或者 ViewModel 订阅了事件聚合器但没取消订阅。解决在 ViewModel 里实现IDisposable导航离开时取消订阅如果模块很重考虑用ContentControl的ContentTemplateSelector手动控制缓存策略。参数上注意EventAggregator.Subscribe返回的 token 要保存Dispose时Unsubscribe。现象三密码框绑定后输入中文或粘贴时值不同步。原因是PasswordBoxHelper里OnBoundPasswordChanged的循环保护写得不严粘贴触发多次PasswordChanged。解决在OnPasswordChanged里加一个_isUpdating标志位赋值期间直接 return。另外PasswordBox对输入法组合字符的处理有延迟必要时监听TextCompositionManager事件。现象四菜单权限过滤后父节点还在但子节点全没了点开是空的。原因是Filter只判断了节点自身权限没判断过滤后子节点是否为空。解决在Filter里先递归处理Children如果Children为空且ViewModelType为 null就跳过该节点。参数上给MenuNode加一个IsLeaf ViewModelType ! null的只读属性判断更清晰。现象五登录成功后主窗口一闪而过程序退出。前面提过根因是ShutdownMode和窗口关闭顺序。解决App.OnStartup里先mainWin.Show()再loginWin.Close()或者显式设置ShutdownMode ShutdownMode.OnExplicitShutdown在真正退出时调Application.Current.Shutdown()。后者更可控但记得所有退出路径都要调否则进程残留。注意以上五个问题里前两个是架构层面的后三个是细节层面的。架构问题越早定规矩越好细节问题靠单元测试和日志兜底。5. 进阶技巧用依赖注入和设计时数据把主界面开发效率拉满到这一步登录和主界面已经能跑通了。但真实 WMS 项目里ViewModel 会越来越多构造函数参数会越来越长手动new会变成灾难。我一般会引入一个轻量依赖注入容器把 Service、ViewModel、窗口都注册进去App.OnStartup只负责Resolve和Show。// 用 Microsoft.Extensions.DependencyInjection 的注册片段 var services new ServiceCollection(); services.AddSingletonIAuthService, AuthService(); services.AddSingletonINavigationService, NavigationService(); services.AddTransientLoginViewModel(); services.AddTransientMainViewModel(); services.AddTransientInboundViewModel(); services.AddTransientOutboundViewModel(); var provider services.BuildServiceProvider();逻辑说明AddSingleton用于无状态或全局共享的服务AddTransient用于每次导航都要新实例的 ViewModel。参数上注意MainViewModel如果持有子 ViewModel子 ViewModel 的注册生命周期要和导航策略匹配——每次切换都新建就用 Transient想缓存就用 Singleton 加手动清理。另一个提效手段是设计时数据。XAML 设计器默认拿不到运行时 DataContext界面一片空白调布局全靠猜。在 ViewModel 里加静态构造方法配合d:DataContext就能在设计器里看到假数据Window x:ClassWms.MainWindow xmlns:dhttp://schemas.microsoft.com/expression/blend/2008 xmlns:mchttp://schemas.openxmlformats.org/markup-compatibility/2006 mc:Ignorabled d:DataContext{d:DesignInstance vm:MainViewModel, IsDesignTimeCreatableTrue}逻辑说明IsDesignTimeCreatableTrue要求MainViewModel有无参构造函数或者设计时能走通构造逻辑。我一般给 ViewModel 加一个DesignTimeMainViewModel子类构造函数里塞假菜单和假仓库只在设计器里生效运行时用 DI 注入真实依赖。这样调主界面布局时不用每次启动整个应用效率提升非常明显。最后一个习惯所有跨模块通信都走事件聚合器不要用Application.Current.MainWindow去强转然后调方法。我早期项目里主界面直接((MainWindow)Application.Current.MainWindow).RefreshStock()后来主窗口类型一改全项目编译不过。改成事件后模块之间只认事件类型重构成本几乎为零。这个教训让我在后来的每个 WPF 项目里第一件事就是定好事件契约。希望帮到你。本文还有配套的精品资源点击获取