前面说了一堆废话,想看代码的可直接看第二章。
版本记录
日期 | 备注 |
---|---|
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日星期六