☰
WPF MVVM WMS仓库系统:登录与主界面架构实战指南
2026/10/10 12:27:08 网站建设 项目流程

简介:这是一套基于 WPF 的 WMS 仓库管理系统示例源码,面向具备一定 C# 与 XAML 基础、希望深入理解 MVVM 落地实践的开发者。项目采用 Stylet 框架搭建 MVVM 结构,配合 MaterialDesign 实现界面样式,数据访问层使用 SqlSugar 操作 SqlServer2008,涵盖登录验证、主界面布局、左侧下拉菜单、DataGrid 查询展示以及部门、用户、客户、供应商等模块的增删改查视图,适合作为企业级桌面管理系统的学习与二次开发参考。资源包共 498 个文件,以 140 个 cs 源码、13 个 xaml 界面文件及 67 个 dll 依赖为主,另含 json、config、csproj 等工程配置,整体约 17.8MB,结构完整可直接编译运行。目前已有 1072 人学习下载。通过该源码可掌握 Stylet 的依赖注入与视图绑定、MaterialDesign 控件定制、SqlSugar 实体映射与查询写法,并借鉴登录、菜单、列表等常见模块的组织方式,快速搭建自己的 WPF 管理端框架。

1. WPF MVVM WMS 仓库系统:登录与主界面为什么值得先啃下来

做过 WMS 的人都知道,仓库管理系统真正的复杂度不在增删改查,而在权限、并发和界面状态同步。而登录页和主界面,恰恰是这套复杂度的第一道闸口。很多团队一上来就铺业务模块,结果做到一半发现登录态传不明白、主界面菜单权限写死在 XAML 里、ViewModel 之间互相 new,最后返工成本比从头搭还高。WPF 配 MVVM 做 WMS,登录和主界面这两个看似简单的页面,其实决定了后面几十个功能页的架构走向。这篇文章面向的是已经会写 C#、摸过 WPF 但没系统落地过 MVVM 的开发者,或者正在从 WinForm 迁到 WPF 的团队。我会把登录窗口、主界面框架、导航、权限菜单这几块拆开讲,给出能直接抄的代码结构和参数配置,也会说清楚哪些地方我踩过坑、哪些设计看起来优雅但实际会翻车。读完你应该能自己搭出一套可扩展的 WMS 前端骨架。

2. 登录模块的 MVVM 拆解:从密码框绑定到登录态落地

2.1 为什么 PasswordBox 不能直接绑定

WPF 的PasswordBox.Password属性不是依赖属性,这是 MVVM 新手第一个翻车点。你写Password="{Binding Password}"会发现根本不生效,而且密码以明文存在 ViewModel 里也不安全。常见做法是写一个附加属性(Attached Property)把 PasswordBox 的密码同步到一个 SecureString 或者临时字符串,登录完成后立即清空。

public static class PasswordBoxHelper { // 附加属性:把 PasswordBox.Password 桥接到 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 (e.NewValue is string pwd && box.Password != pwd) box.Password = pwd; box.PasswordChanged += OnPasswordChanged; } private static void OnPasswordChanged(object sender, RoutedEventArgs e) { var box = (PasswordBox)sender; SetBoundPassword(box, box.Password); } }

这段代码的关键在于BindsTwoWayByDefault和事件解绑再绑定的顺序。如果不先解绑,ViewModel 更新密码时会触发PasswordChanged,又反过来更新 ViewModel,形成死循环。参数上,FrameworkPropertyMetadataOptions.BindsTwoWayByDefault让 XAML 里不用显式写Mode=TwoWay,减少遗漏。实际使用时在 XAML 里写local:PasswordBoxHelper.BoundPassword="{Binding Password, UpdateSourceTrigger=PropertyChanged}",UpdateSourceTrigger设成PropertyChanged是为了让登录按钮的 CanExecute 能实时响应输入。

2.2 登录命令与异步校验的写法

登录按钮不应该在 Click 事件里写逻辑,而是绑定一个ICommand。我一般用AsyncRelayCommand模式,因为登录要调后端接口,同步命令会卡 UI 线程。

public class LoginViewModel : ViewModelBase { private readonly IAuthService _authService; private string _userName = string.Empty; private string _password = string.Empty; private bool _isBusy; public string UserName { get => _userName; set => SetProperty(ref _userName, value); } public string Password { get => _password; set => SetProperty(ref _password, value); } public bool IsBusy { get => _isBusy; set => SetProperty(ref _isBusy, value); } public ICommand LoginCommand { get; } public LoginViewModel(IAuthService authService) { _authService = authService; LoginCommand = new AsyncRelayCommand(ExecuteLogin, CanExecuteLogin); } private bool CanExecuteLogin() => !string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(Password) && !IsBusy; private async Task ExecuteLogin() { IsBusy = true; try { var result = await _authService.LoginAsync(UserName, Password); if (result.Success) { // 登录成功,保存 Token 和用户信息 AppSession.CurrentUser = result.User; AppSession.Token = result.Token; // 通知窗口关闭或切换主界面 LoginCompleted?.Invoke(this, EventArgs.Empty); } else { ErrorMessage = result.Message; } } finally { IsBusy = false; // 登录后清空内存中的密码 Password = string.Empty; } } public event EventHandler? LoginCompleted; public string ErrorMessage { get; private set; } = string.Empty; }

AsyncRelayCommand需要自己实现或者用社区库,核心是Execute里await任务,同时CanExecute在IsBusy为 true 时返回 false,防止重复点击。参数上注意SetProperty要触发CanExecuteChanged,否则按钮的禁用状态不会刷新。登录成功后把用户信息放到一个静态的AppSession或者依赖注入的单例里,不要塞进Application.Current.Properties,那个字典类型不安全,后期维护很痛苦。

2.3 登录窗口如何切换到主界面

常见做法是在App.xaml.cs里重写OnStartup,先显示登录窗口,用ShowDialog拿到结果,再决定是否创建主窗口。

protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); var login = new LoginWindow(); var loginVm = new LoginViewModel(new AuthService()); login.DataContext = loginVm; loginVm.LoginCompleted += (_, _) => { login.DialogResult = true; login.Close(); }; if (login.ShowDialog() == true) { var main = new MainWindow(); main.DataContext = new MainViewModel(); main.Show(); } else { Shutdown(); } }

这里有个细节:ShowDialog会阻塞,但LoginCompleted事件里设置DialogResult后窗口关闭,ShowDialog返回 true。不要在登录窗口里直接new MainWindow().Show(),那样登录窗口的生命周期和主窗口耦合,后面做注销重新登录时会很麻烦。参数上,Shutdown()确保用户关闭登录窗口时整个应用退出,而不是留一个空进程。

3. 主界面框架:导航、区域与权限菜单的动态生成

3.1 用 ContentControl 做导航宿主

主界面最常见的布局是左侧菜单、顶部工具栏、中间内容区。内容区不要用多个 UserControl 叠在一起切换 Visibility,那样内存里会同时存在所有页面。正确做法是用一个ContentControl绑定到 ViewModel 的CurrentView属性。

<Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="220"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <TreeView Grid.Column="0" ItemsSource="{Binding MenuItems}" SelectedItemChanged="TreeView_SelectedItemChanged"/> <ContentControl Grid.Column="1" Content="{Binding CurrentView}"/> </Grid>

CurrentView是一个object类型,实际放的是 ViewModel 实例,通过 DataTemplate 匹配对应的 View。在App.xaml里定义模板映射:

<DataTemplate DataType="{x:Type vm:InventoryViewModel}"> <views:InventoryView/> </DataTemplate> <DataTemplate DataType="{x:Type vm:OrderViewModel}"> <views:OrderView/> </DataTemplate>

这样切换页面时 WPF 自动根据 ViewModel 类型找到 View,不需要在代码里写switch。参数上注意ContentControl默认会缓存内容,如果页面需要每次刷新,可以在切换时重新 new 一个 ViewModel 实例,或者实现INavigationAware接口在激活时刷新数据。

3.2 权限菜单从接口动态生成

WMS 的菜单权限通常由后端返回一个树形结构,前端根据权限过滤。不要在 XAML 里写死菜单项,也不要用Visibility隐藏,那样只是视觉隐藏,用户还是能通过其他方式访问。正确做法是登录后拉取权限列表,在 ViewModel 里构建MenuItems集合。

public class MainViewModel : ViewModelBase { public ObservableCollection<MenuItemViewModel> MenuItems { get; } = new(); public async Task LoadMenusAsync() { var permissions = await _menuService.GetUserMenusAsync( AppSession.CurrentUser.RoleId); MenuItems.Clear(); foreach (var p in permissions) { // 只添加有权限且类型为菜单的节点 if (!p.IsAuthorized || p.Type != "Menu") continue; MenuItems.Add(new MenuItemViewModel { Title = p.Title, Icon = p.Icon, ViewModelType = ResolveViewModelType(p.Code), Children = BuildChildren(p.Children) }); } } private Type? ResolveViewModelType(string code) { // 用约定优于配置:菜单编码对应 ViewModel 类名 return Type.GetType($"Wms.ViewModels.{code}ViewModel"); } }

ResolveViewModelType用反射按命名约定找 ViewModel,省去一个巨大的 switch。参数上,code建议后端返回英文标识如Inventory、Order,前端拼成InventoryViewModel。如果找不到类型,记录日志并跳过,不要让整个菜单加载失败。Children递归构建,注意ObservableCollection的线程问题,如果接口回调在非 UI 线程,需要用Dispatcher.Invoke包裹集合更新。

3.3 导航参数传递与页面缓存

从菜单点进详情页时经常需要传参,比如订单号。我一般定义一个INavigationService,在 ViewModel 里注入,调用NavigateTo<T>(parameter)。

public interface INavigationService { void NavigateTo<TViewModel>(object? parameter = null) where TViewModel : ViewModelBase; } public class NavigationService : INavigationService { private readonly IServiceProvider _provider; private readonly MainViewModel _main; public NavigationService(IServiceProvider provider, MainViewModel main) { _provider = provider; _main = main; } public void NavigateTo<TViewModel>(object? parameter = null) where TViewModel : ViewModelBase { var vm = _provider.GetRequiredService<TViewModel>(); if (vm is INavigationAware aware) aware.OnNavigatedTo(parameter); _main.CurrentView = vm; } }

INavigationAware接口定义OnNavigatedTo和OnNavigatedFrom,页面在激活时刷新数据、离开时保存状态。参数上,parameter用object是为了通用,实际用的时候在OnNavigatedTo里强转。注意GetRequiredService默认每次创建新实例,如果某个页面需要缓存,注册服务时用单例或者自己维护一个字典。

4. 避坑与排查:登录和主界面最容易翻车的五个地方

4.1 登录后主界面菜单空白

现象是登录成功,主窗口打开了,但左侧菜单一片空白。原因通常是权限接口是异步的,MainViewModel构造函数里调用了LoadMenusAsync但没有await,或者ObservableCollection在非 UI 线程更新导致绑定失效。解决方法是把菜单加载放到Loaded事件或者OnNavigatedTo里,用async void或者Dispatcher.Invoke确保集合更新在 UI 线程。排查时在MenuItems.Add前后打日志,看集合 Count 是否变化。

4.2 PasswordBox 绑定后密码框显示异常

现象是输入密码时光标跳动、或者密码被清空后无法再次输入。原因是附加属性的OnBoundPasswordChanged里没有正确解绑事件,导致循环触发。解决方法是严格按照前面代码里的顺序:先-=再判断赋值再+=。另外UpdateSourceTrigger不要用LostFocus,否则登录按钮的可用状态不会实时更新。

4.3 主界面切换页面时旧页面不释放

现象是反复切换菜单后内存持续增长,用性能分析器能看到 ViewModel 实例越来越多。原因是ContentControl的Content每次赋新值,旧的 ViewModel 如果没有被引用应该被回收,但如果 ViewModel 订阅了静态事件或者被单例服务持有,就会泄漏。解决方法是检查 ViewModel 里有没有static event的订阅,在OnNavigatedFrom里取消订阅。另外NavigationService如果用GetRequiredService每次创建新实例,旧实例自然释放,但如果注册成了单例,就要手动清理。

4.4 登录窗口关闭后进程不退出

现象是用户点登录窗口的关闭按钮,窗口消失了但进程还在任务管理器里。原因是ShowDialog返回后没有调用Shutdown(),或者主窗口没有正确关闭。解决方法是在OnStartup里判断ShowDialog的返回值,非 true 时调用Shutdown()。另外检查有没有后台线程没有设为IsBackground = true,非后台线程会阻止进程退出。

4.5 权限菜单点击后报 ViewModel 找不到

现象是菜单项能显示,但点击后抛出Type.GetType返回 null 的异常。原因是菜单编码和 ViewModel 类名的命名约定不一致,比如后端返回inventory_list,前端拼成inventory_listViewModel,而实际类名是InventoryViewModel。解决方法是统一约定:后端返回 PascalCase 的编码,或者前端做一个映射表。排查时在ResolveViewModelType里打日志输出拼接后的类型名,对比实际类名。

5. 进阶技巧:用区域管理和延迟加载把主界面做轻

主界面做久了会发现,所有页面都塞进一个ContentControl,启动时反射加载所有 ViewModel 类型,启动速度会越来越慢。我后来习惯把主界面拆成几个区域(Region),每个区域独立导航,再配合延迟加载。具体做法是定义一个RegionManager,每个区域有自己的ContentControl和导航栈。

public class RegionManager { private readonly Dictionary<string, Stack<ViewModelBase>> _stacks = new(); public void Navigate(string region, ViewModelBase vm) { if (!_stacks.ContainsKey(region)) _stacks[region] = new Stack<ViewModelBase>(); _stacks[region].Push(vm); RegionChanged?.Invoke(region, vm); } public void GoBack(string region) { if (_stacks.TryGetValue(region, out var stack) && stack.Count > 1) { stack.Pop(); RegionChanged?.Invoke(region, stack.Peek()); } } public event Action<string, ViewModelBase>? RegionChanged; }

主界面里放多个ContentControl,每个绑定到RegionManager对应区域的当前 ViewModel。这样订单列表和订单详情可以放在不同区域,详情关闭时列表状态还在。延迟加载方面,菜单项点击时才通过ResolveViewModelType创建实例,不要启动时把所有 ViewModel 都注册进 DI 容器。参数上,RegionManager的栈深度建议限制在 10 以内,太深了用户也找不到返回路径。

验证这套结构是否合理,我一般用两个指标:一是启动到主界面可交互的时间,控制在 2 秒以内;二是连续切换 20 个菜单后,内存增长不超过 50MB。如果超标,优先查 ViewModel 里的静态引用和事件订阅。另一个技巧是把DataTemplate的映射放到独立的ResourceDictionary里,按模块拆成多个文件,主界面只合并需要的模块,这样 XAML 解析也快。

我自己踩过最深的坑是早期把AppSession写成了静态类,里面存了User和Token,结果单元测试没法并行跑,因为静态状态互相污染。后来改成依赖注入的单例,测试时用 Mock 替换,清爽很多。如果你也在搭 WMS 的 WPF 前端,建议从登录和主界面这两块开始就把 DI 和导航服务定好,后面加页面就是复制粘贴的事。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询