前面说了一堆废话,想看代码的可直接看第二章。
版本记录
日期 | 备注 |
---|---|
2020-06-13 | 初稿 |
零、前言
这个登录界面提取自最近正在做的一个项目,此项目曾被我自豪地称为是公司数采软件的颜值担当
,虽然这里面有不少夸大的成分,但也并非担不起这个称号。这个项目能做出今天的程度,是项目组一起努力的结果,并非我一个人的功劳。
纵观公司这么多年的数采软件界面,清一色的都是很刻板、很标准的Windows风格。甚至在嵌入式数采仪上,这么多年来都是沿袭了Win8风格的磁贴界面,一成不变。我并不是说界面古板、简陋、一成不变不好,曾经的界面、功能能沿袭这么多年,最大的原因无非就是稳定。对于工控数采软件来说,稳定性尤其重要,一般都要求7×24小时不间断运行,界面简洁易用就够了。当然这也不是说工控数采软件不配拥有花哨华丽的界面,在我看来,以往一成不变的界面、特别是不管什么业务都复用同一个界面结构,虽然减少了开发工作,但最终出来的产品往往都千篇一律,突出不了软件的特色。
自从我接手柄主刀DATS(即所谓的数据采集传输系统
)系列项目开发后,最开始分离了核心数采功能和业务功能,提炼出来一个相对纯粹的DATS内核
,业务系统都基于此内核进行开发。
有关数采的内核架构,不是三言两语就能说得清。今天不聊数采,我只想聊聊其中最常见、但放在数采软件上很容易被忽略的的一个功能——用户登录。
一、登录界面的演化
用户登录
功能在任何一个B/S、移动App项目上都有,甚至还会花大功夫去设计、打磨出一个耳目一新的界面。但对于公司的数采软件来说,一直都没重视过,有的场合还会带来不好的操作体验。
1.1 数采软件对权限控制的特殊要求
说起登录界面,那首先得聊聊权限控制。
数采软件正常都是无人值守运行的,但当需要维护、修改设置,尤其是需要下置命令修改仪器内部的设置时,如果任何人都有权限去操控,后果会很严重。这时候就需要一套简单的权限控制功能,这个权限控制
功能跟B/S项目上的还略有出入。B/S项目中通常基于角色实现权限控制,角色对应的权限可以动态配置,但在缺少上下位一体的云平台的情况下,纯粹基于角色控制权限的思路并不完全适用于DATS。
在DATS的权限控制模块中,定义了四个等级的角色。
- 普通用户
- 高级用户
- 管理员
- 超级管理员
除超级管理员
之外,其余三个角色具备的权限由具体地业务系统分配,且分配好了就不能任意更改。这也是和常规B/S系统中的权限控制不同的地方。
超级管理员
是系统内最高权限的角色,我们内置了唯一个超级管理员账号,此账号仅用于开发组,不对实施、售后、甚至客户公开。
1.2 前世
简单地提了下权限控制,下面贴几张图,看看之前几个系统的登录界面长啥样。
- 前辈做的二代数采登录界面,是目前在运行的最古老的数采软件
- 第三代数采登录界面
- 第四代数采初期登录界面
这三个界面的时间跨度超过六年,开发语言也从VB6换到C#,但外表完完全全是一个模子里刻出来的,除了简约,看不出别的特点了
1.3 演变
自从去年开始用WPF技术重构整个数采系统的UI,登录界面已经改过一个版本了,就是下面这个样子。
操作逻辑是当执行需要进行权限控制的功能前,如果发现尚未登录,则通过消息框提示用户。用户需要手动去点登录
按钮,弹出该对话框,通过用户名和密码登录到系统,再返回到刚才要执行的操作,如下(请忽略原先的一个Bug)。
这样的操作方式对于用户来说很不友好,特别是来回切换可能会造成上一次执行结果的丢失,让软件的易用性下降不止一个档次。
尤其是手头这个项目最终运行在带触摸屏的Windows一体机中,在没有鼠标键盘的情况下,让用户通过软键盘做重复的操作简直是噩梦,这怎么对得起颜值担当
的称号呢?
1.4 今生
考虑到操作方式以触摸为主,我参考了手机银行App的交互逻辑,重新设计了如下的登录流程。
从上图中可以看出,正常运行时系统无需登录,当某个需要登录才能执行操作前,自动弹出登录画面,通过用户名和密码登录到系统后,此页面自动关闭,并继续往下执行原有逻辑。
大概的操作效果如下,交互已经友好多了,基本达到了我期望。
二、登录界面的实现
2.1 思路
要实现上面的效果,其实也不难。说下思路吧。
将登录界面封装为一个用户控件放到主界面底部,通过设置高度为0,确保主界面刚显示的时候隐藏登录界面。绑定IsShow
属性来实现调出和隐藏,当需要登录时,设置IsShow
=True
,通过StoryBoard
增加其高度,直到铺满整个操作区域。登录成功或点击左上角的返回
按钮,则设置IsShow
=False
,通过StoryBoard
减少高度直至0。
2.2 代码
直接上代码,很简单,一看就懂,懒得写注释了。
首先是登录界面的ViewModel,通过单例模式保证全局共用同一个实例。其中WaitForLogin
方法由需要权限控制的界面调用,登录成功后通过回调函数继续执行原有的逻辑。
class LoginViewModel : BaseViewModel
{private static LoginViewModel s_Instance = null;public static LoginViewModel Current{get{if (s_Instance == null){s_Instance = new LoginViewModel();}return s_Instance;}}private string m_LoginName;public string LoginName{get { return m_LoginName; }set{if (m_LoginName != value){m_LoginName = value;OnPropertyChanged(nameof(LoginName));}}}private string m_Password;public string Password{get { return m_Password; }set{if (m_Password != value){m_Password = value;OnPropertyChanged(nameof(Password));}}}private bool m_IsShow = false;public bool IsShow{get { return m_IsShow; }set{if (m_IsShow != value){LoginName = "";Password = "";m_IsShow = value;OnPropertyChanged(nameof(IsShow));}}}private LoginViewModel(){Minimize = new SimpleCommand(a => true, OnMinimize);Login = new SimpleCommand(a => true, OnLogin);}public ICommand Minimize { get; private set; }private void OnMinimize(object obj){IsShow = false;}public ICommand Login { get; private set; }private void OnLogin(object obj){if (string.IsNullOrEmpty(LoginName)){ShowWarning("用户名不能为空");return;}if (string.IsNullOrEmpty(Password)){ShowWarning("密码不能为空");return;}if (AccountService.LoginByPassword(LoginName, Password, false, out string errMsg)){this.IsShow = false;}else{this.Password = string.Empty;ShowError(errMsg);}}public Task WaitForLogin(Action callback){return Task.Run(() =>{if (!LoginAccountInfo.Current.IsLogin){IsShow = true;do{Thread.Sleep(500);}while (this.IsShow);}if (LoginAccountInfo.Current.IsLogin){App.Current.Dispatcher.Invoke(() =>{callback();});}});}
}
登录界面布局代码如下。
<UserControl x:Class="DATS.WasteWater.UI.Views.Permission.LoginView"xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"xmlns:d="http://schemas.microsoft.com/expression/blend/2008"xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"xmlns:local="clr-namespace:DATS.WasteWater.UI.Views.Permission"xmlns:hc="https://handyorg.github.io/handycontrol"xmlns:input="clr-namespace:System.Windows.Input;assembly=PresentationCore"xmlns:ViewModel="clr-namespace:DATS.WasteWater.UI.ViewModels.Permission"xmlns:ctrlib="http://sinoyd.gitlab.com/ctrlib"mc:Ignorable="d"x:Name="uc"d:DataContext="{d:DesignInstance ViewModel:LoginViewModel,IsDesignTimeCreatable=False}"d:DesignHeight="350" d:DesignWidth="300" Background="Transparent"><UserControl.Resources><Style x:Key="masklayerBackButton" TargetType="Button"><Setter Property="Height" Value="30" /><Setter Property="Width" Value="75" /><Setter Property="Background" Value="Transparent" /><Setter Property="Foreground" Value="White" /><Setter Property="Template"><Setter.Value><ControlTemplate><Border x:Name="grdMain" Padding="{TemplateBinding Padding}" Background="{TemplateBinding Background}" Width="{TemplateBinding Width}" Height="{TemplateBinding Height}" SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}"><StackPanel Orientation="Horizontal" HorizontalAlignment="Center"><TextBlock Style="{StaticResource fa_angle_left}" Margin="0 0 5 0" VerticalAlignment="Center" HorizontalAlignment="Center" /><TextBlock Text="返回" VerticalAlignment="Center" HorizontalAlignment="Center" /></StackPanel></Border></ControlTemplate></Setter.Value></Setter><Style.Triggers><Trigger Property="IsPressed" Value="True"><Setter Property="Background" Value="#3FFF" /></Trigger></Style.Triggers></Style></UserControl.Resources><UserControl.Style><Style TargetType="UserControl"><Style.Triggers><DataTrigger Binding="{Binding IsShow}" Value="True"><DataTrigger.EnterActions><StopStoryboard BeginStoryboardName="collapseStoryBoard" /><BeginStoryboard x:Name="expandStoryBoard"><Storyboard><DoubleAnimation Storyboard.TargetProperty="Height" BeginTime="00:00:00" From="0" To="718" DecelerationRatio="1" Duration="00:00:00.300"/><BooleanAnimationUsingKeyFrames BeginTime="00:00:00.300" Storyboard.TargetProperty="IsEnabled"><DiscreteBooleanKeyFrame Value="False" KeyTime="0:0:0" /><DiscreteBooleanKeyFrame Value="True" KeyTime="0:0:0.1" /></BooleanAnimationUsingKeyFrames></Storyboard></BeginStoryboard></DataTrigger.EnterActions><DataTrigger.ExitActions><StopStoryboard BeginStoryboardName="expandStoryBoard"/><BeginStoryboard x:Name="collapseStoryBoard"><Storyboard><DoubleAnimation Storyboard.TargetProperty="Height" BeginTime="00:00:00" From="718" To="0" DecelerationRatio="1" Duration="00:00:00.300"/><BooleanAnimationUsingKeyFrames BeginTime="00:00:00.300" Storyboard.TargetProperty="IsEnabled"><DiscreteBooleanKeyFrame Value="False" KeyTime="0:0:0" /></BooleanAnimationUsingKeyFrames></Storyboard></BeginStoryboard></DataTrigger.ExitActions></DataTrigger></Style.Triggers></Style></UserControl.Style><Grid><!--遮罩层--><Grid Background="#9000"></Grid><Button x:Name="backBtn" Margin="0 10 0 0" HorizontalAlignment="Left" VerticalAlignment="Top" Command="{Binding Minimize}" Style="{StaticResource masklayerBackButton}" /><StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"><Image Height="100" Width="100" HorizontalAlignment="Center" Source="/DATS.WasteWater.UI;component/Resources/Images/userIcon.jpg" Margin="0 0 0 50"><Image.Clip><GeometryGroup FillRule="Nonzero"><EllipseGeometry RadiusX="50" RadiusY="50" Center="50,50" /></GeometryGroup></Image.Clip></Image><ContentControl x:Name="ctLoginName" Grid.Row="1" VerticalAlignment="Center" Height="40" Grid.Column="1" HorizontalAlignment="Stretch" Width="220" IsTabStop="False"><Border BorderBrush="Silver" BorderThickness="1" Background="White" Padding="10 0 2 0" CornerRadius="5"><Grid><Grid.ColumnDefinitions><ColumnDefinition Width="25"/><ColumnDefinition/></Grid.ColumnDefinitions><TextBlock Style="{StaticResource fa_account}" Margin="0,0,5,0" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="18" /><TextBox x:Name="txtLoginName" Grid.Column="1" Text="{Binding LoginName}" Style="{StaticResource TextBoxExtend}" hc:InfoElement.Placeholder="用户名" hc:InfoElement.Necessary="True" HorizontalAlignment="Stretch" FontSize="15" VerticalContentAlignment="Center" BorderThickness="0" MaxLength="15" TabIndex="1" input:InputMethod.IsInputMethodEnabled="False" IsEnabledChanged="txtLoginName_IsEnabledChanged"/></Grid></Border></ContentControl><ContentControl x:Name="ctPassword" Grid.Row="2" VerticalAlignment="Center" Margin="10" Height="40" Grid.Column="1" HorizontalAlignment="Stretch" Width="220" IsTabStop="False"><Border BorderBrush="Silver" BorderThickness="1" Background="White" Padding="10 0 2 0" CornerRadius="5"><Grid><Grid.ColumnDefinitions><ColumnDefinition Width="25"/><ColumnDefinition/></Grid.ColumnDefinitions><TextBlock Style="{StaticResource fa_lock}" Margin="0,0,5,0" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="18" /><PasswordBox x:Name="txtPassword" Grid.Column="1" Style="{StaticResource PasswordBoxExtend}" hc:InfoElement.Placeholder="密码" hc:InfoElement.Necessary="True" HorizontalAlignment="Stretch" FontSize="15" VerticalContentAlignment="Center" BorderThickness="0" MaxLength="15" TabIndex="2"ctrlib:PasswordBoxHelper.Attach="True" ctrlib:PasswordBoxHelper.Password="{Binding Password, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /></Grid></Border></ContentControl><Button Height="35" Content="登录" Style="{StaticResource ButtonPrimary}" FontSize="14" HorizontalAlignment="Center" Name="btnLogin" VerticalAlignment="Center" Width="220" Grid.Row="3" TabIndex="3" BorderBrush="DarkGray" FontWeight="Normal" Command="{Binding Login}" IsDefault="True" /></StackPanel></Grid>
</UserControl>
设计时的效果如下,在运行的时候会铺满整个屏幕。
2.3 调用
到这一步,我们已经实现了一套沉浸式的用户登录界面以及交互流程,在业务模块中只需要通过这几行代码即可调出登录界面。
await LoginViewModel.Current.WaitForLogin(() =>
{// 登录成功后执行的业务逻辑
});
三、增加背景高斯模糊效果
仔细的读者可能会注意到上面的效果图中,背景做了模糊效果。没错,就是这个样子。
而刚才的代码并没有实现这么个效果。其实这个高斯模糊(也就是BlurEffect
)并没有加在登录界面上,了解WPF的同学都清楚,在页面的根元素上加入BlurEffect
会导致该节目整个都被模糊了。
因此,高斯模糊只能加在背景层上。我这边是通过StroyBoard
在显示的时候逐步增加模糊程度,关闭的时候逐步减少模糊程序,实现了渐变的效果。对于用户来说,有一个平滑的过度,视觉上不会很突兀。
代码如下
<Grid x:Name="grdBody" Grid.Row="1"><Grid.Effect><BlurEffect x:Name="bodyBlurEffect" Radius="0" RenderingBias="Performance" /></Grid.Effect><Grid.Style><Style TargetType="Grid"><Style.Triggers><DataTrigger Binding="{Binding LoginViewModel.IsShow}" Value="True"><DataTrigger.EnterActions><StopStoryboard BeginStoryboardName="unBlurStoryBoard" /><BeginStoryboard x:Name="blurStoryBoard"><Storyboard><DoubleAnimation Storyboard.TargetProperty="(Grid.Effect).(BlurEffect.Radius)" BeginTime="00:00:00" From="0" To="30" DecelerationRatio="1" Duration="00:00:00.300"/></Storyboard></BeginStoryboard></DataTrigger.EnterActions><DataTrigger.ExitActions><StopStoryboard BeginStoryboardName="blurStoryBoard" /><BeginStoryboard x:Name="unBlurStoryBoard"><Storyboard><DoubleAnimation Storyboard.TargetProperty="(Grid.Effect).(BlurEffect.Radius)" BeginTime="00:00:00" From="30" To="0" DecelerationRatio="1" Duration="00:00:00.300"/></Storyboard></BeginStoryboard></DataTrigger.ExitActions></DataTrigger></Style.Triggers></Style></Grid.Style><Frame x:Name="frameHost" Visibility="{Binding IsRealMonitorVisible, Converter={StaticResource booleanToUnVisibilityConverter}}" Source="{Binding DataContext.CurrentUrl, ElementName=mainWindow, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" NavigationUIVisibility="Hidden" FocusVisualStyle="{x:Null}" Background="{DynamicResource DefaultBrush}" /><Frame x:Name="frameRealTimeMonitor" Visibility="{Binding IsRealMonitorVisible, Converter={StaticResource booleanToVisibilityConverter}}" Source="/DATS.WasteWater.UI;component/Views/RealTimeMonitor/FactorViewPage.xaml" NavigationUIVisibility="Hidden" FocusVisualStyle="{x:Null}" Background="{DynamicResource DefaultBrush}" />
</Grid>
四、尾声
到此为止,一个能配地上颜值担当
称号的登录界面就完成了。本文的主要目的只是陈述下思路,贴的代码中还引用了第三方库,直接复制是没法运行的,而且暂时也没有抽取Demo的计划。
2020年6月13日星期六