|
|
@ -8,9 +8,8 @@
|
||||||
<ResourceDictionary>
|
<ResourceDictionary>
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||||
<!-- Other merged dictionaries here -->
|
<ResourceDictionary Source="ms-appx:///Styles/KronaTheme.xaml" />
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
<!-- Other app resources here -->
|
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ public partial class App : Application
|
||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
UnhandledException += (_, eventArgs) => CrashLogService.Write(eventArgs.Exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 130 KiB |
BIN
apps/KronaStaffTerminal/Assets/KronaAppIcon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/KronaStaffTerminal/Assets/KronaAppMark.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 574 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 5.1 KiB |
BIN
apps/KronaStaffTerminal/Assets/TrushenkoLogo.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 112 KiB |
|
|
@ -5,6 +5,7 @@
|
||||||
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
|
||||||
<RootNamespace>KronaStaffTerminal</RootNamespace>
|
<RootNamespace>KronaStaffTerminal</RootNamespace>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
|
||||||
<Platforms>x86;x64;ARM64</Platforms>
|
<Platforms>x86;x64;ARM64</Platforms>
|
||||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
|
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
|
||||||
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
|
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
|
||||||
|
|
@ -27,6 +28,9 @@
|
||||||
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
|
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
|
||||||
<Content Include="Assets\StoreLogo.png" />
|
<Content Include="Assets\StoreLogo.png" />
|
||||||
<Content Include="Assets\AppIcon.ico" />
|
<Content Include="Assets\AppIcon.ico" />
|
||||||
|
<Content Include="Assets\KronaAppMark.png" />
|
||||||
|
<Content Include="Assets\KronaAppIcon.png" />
|
||||||
|
<Content Include="Assets\TrushenkoLogo.png" />
|
||||||
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
Unloaded="Page_Unloaded"
|
Unloaded="Page_Unloaded"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
|
|
||||||
<Grid Padding="24,16,24,24" RowSpacing="12">
|
<Grid Padding="24,16,24,24"
|
||||||
|
RowSpacing="12"
|
||||||
|
Background="{ThemeResource KronaAppBackgroundBrush}">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
|
|
@ -23,25 +25,75 @@
|
||||||
Title="Подключение"
|
Title="Подключение"
|
||||||
Message="Запуск терминала…"/>
|
Message="Запуск терминала…"/>
|
||||||
|
|
||||||
<TabView Grid.Row="1" IsAddTabButtonVisible="False">
|
<TabView x:Name="MainTabs"
|
||||||
<TabViewItem Header="Терминал" IsClosable="False">
|
Grid.Row="1"
|
||||||
|
Background="{ThemeResource KronaWorkspaceBrush}"
|
||||||
|
IsAddTabButtonVisible="False"
|
||||||
|
SelectionChanged="MainTabs_SelectionChanged">
|
||||||
|
<TabViewItem x:Name="TerminalTab" Header="Терминал" IsClosable="False">
|
||||||
<Grid Padding="4,16,4,4" ColumnSpacing="24">
|
<Grid Padding="4,16,4,4" ColumnSpacing="24">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="2*"/>
|
<ColumnDefinition Width="2*"/>
|
||||||
<ColumnDefinition Width="3*"/>
|
<ColumnDefinition Width="3*"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="16">
|
<Border Margin="8"
|
||||||
<FontIcon Glyph="" FontSize="88"/>
|
Padding="28"
|
||||||
<TextBlock Text="Приложите палец" Style="{StaticResource TitleTextBlockStyle}" HorizontalAlignment="Center"/>
|
Background="{ThemeResource KronaSurfaceBrush}"
|
||||||
<TextBlock x:Name="TerminalMessage" Text="Ожидание сканирования" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="360"/>
|
BorderBrush="{ThemeResource KronaBorderBrush}"
|
||||||
</StackPanel>
|
BorderThickness="1"
|
||||||
|
CornerRadius="{StaticResource KronaRadiusLarge}">
|
||||||
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14">
|
||||||
|
<Grid Width="220" Height="220">
|
||||||
|
<Ellipse Fill="{ThemeResource KronaGoldSoftBrush}" />
|
||||||
|
<Image Margin="18"
|
||||||
|
Source="Assets/TrushenkoLogo.png"
|
||||||
|
Stretch="Uniform"
|
||||||
|
AutomationProperties.Name="Логотип Трушенко" />
|
||||||
|
<Border Width="54"
|
||||||
|
Height="54"
|
||||||
|
Margin="0,0,4,4"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Background="{ThemeResource KronaPrimaryBrush}"
|
||||||
|
CornerRadius="27">
|
||||||
|
<FontIcon Glyph=""
|
||||||
|
FontSize="27"
|
||||||
|
Foreground="{ThemeResource KronaTextInverseBrush}" />
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="Приложите палец"
|
||||||
|
Foreground="{ThemeResource KronaTextPrimaryBrush}"
|
||||||
|
Style="{StaticResource TitleTextBlockStyle}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock x:Name="TerminalMessage"
|
||||||
|
MaxWidth="360"
|
||||||
|
Foreground="{ThemeResource KronaTextSecondaryBrush}"
|
||||||
|
Text="Ожидание сканирования"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
TextAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<Grid Grid.Column="1" RowSpacing="12">
|
<Grid Grid.Column="1" RowSpacing="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<TextBlock Text="Последние отметки" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
<Grid ColumnSpacing="12">
|
||||||
<ListView x:Name="AttendanceList" Grid.Row="1" SelectionMode="None">
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<TextBlock Text="Последние отметки"
|
||||||
|
Foreground="{ThemeResource KronaTextPrimaryBrush}"
|
||||||
|
Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Очистить журнал"
|
||||||
|
Click="ClearAttendance_Click" />
|
||||||
|
</Grid>
|
||||||
|
<ListView x:Name="AttendanceList"
|
||||||
|
Grid.Row="1"
|
||||||
|
Background="{ThemeResource KronaRaisedSurfaceBrush}"
|
||||||
|
SelectionMode="None">
|
||||||
<ListView.ItemTemplate>
|
<ListView.ItemTemplate>
|
||||||
<DataTemplate x:DataType="models:AttendanceRecord">
|
<DataTemplate x:DataType="models:AttendanceRecord">
|
||||||
<Grid Padding="4,10" ColumnSpacing="16">
|
<Grid Padding="4,10" ColumnSpacing="16">
|
||||||
|
|
@ -63,19 +115,30 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabViewItem>
|
</TabViewItem>
|
||||||
|
|
||||||
<TabViewItem Header="Сотрудники" IsClosable="False">
|
<TabViewItem x:Name="EmployeesTab" Header="Сотрудники" IsClosable="False">
|
||||||
<Grid Padding="4,16,4,4" RowSpacing="12">
|
<Grid Padding="4,16,4,4" RowSpacing="12">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<TextBlock Text="Добавление и регистрация сотрудников" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
<TextBlock Text="Добавление и регистрация сотрудников" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||||
<CommandBar Grid.Row="1" DefaultLabelPosition="Right">
|
<CommandBar Grid.Row="1" Background="{ThemeResource KronaSurfaceBrush}" DefaultLabelPosition="Right">
|
||||||
<AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/>
|
<AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/>
|
||||||
<AppBarButton Icon="Contact" Label="Зарегистрировать отпечаток" Click="RegisterFingerprint_Click"/>
|
<AppBarButton Icon="Contact" Label="Добавить отпечаток" Click="RegisterFingerprint_Click"/>
|
||||||
|
<AppBarButton Label="Удалить отпечатки" Click="DeleteFingerprints_Click">
|
||||||
|
<AppBarButton.Icon>
|
||||||
|
<FontIcon Glyph="" />
|
||||||
|
</AppBarButton.Icon>
|
||||||
|
</AppBarButton>
|
||||||
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
|
<AppBarButton Icon="Permissions" Label="Права администратора" Click="ToggleAdministrator_Click"/>
|
||||||
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
|
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
|
||||||
|
<AppBarSeparator />
|
||||||
|
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
|
||||||
|
<AppBarButton.Icon>
|
||||||
|
<FontIcon Glyph="" />
|
||||||
|
</AppBarButton.Icon>
|
||||||
|
</AppBarButton>
|
||||||
</CommandBar>
|
</CommandBar>
|
||||||
<Grid Grid.Row="2" ColumnSpacing="20">
|
<Grid Grid.Row="2" ColumnSpacing="20">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|
@ -86,25 +149,32 @@
|
||||||
<TextBox x:Name="PersonnelNumberBox" Header="Табельный номер" PlaceholderText="Например, 001" AutomationProperties.Name="Табельный номер"/>
|
<TextBox x:Name="PersonnelNumberBox" Header="Табельный номер" PlaceholderText="Например, 001" AutomationProperties.Name="Табельный номер"/>
|
||||||
<TextBox x:Name="EmployeeNameBox" Header="Имя сотрудника" PlaceholderText="Иван Петров" AutomationProperties.Name="Имя сотрудника"/>
|
<TextBox x:Name="EmployeeNameBox" Header="Имя сотрудника" PlaceholderText="Иван Петров" AutomationProperties.Name="Имя сотрудника"/>
|
||||||
<TextBox x:Name="PositionBox" Header="Должность" PlaceholderText="Например, менеджер" AutomationProperties.Name="Должность"/>
|
<TextBox x:Name="PositionBox" Header="Должность" PlaceholderText="Например, менеджер" AutomationProperties.Name="Должность"/>
|
||||||
|
<ComboBox x:Name="GenderComboBox" Header="Пол" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="Не указан" />
|
||||||
|
<ComboBoxItem Content="Мужской" />
|
||||||
|
<ComboBoxItem Content="Женский" />
|
||||||
|
</ComboBox>
|
||||||
<CheckBox x:Name="AdministratorCheckBox" Content="Администратор терминала"/>
|
<CheckBox x:Name="AdministratorCheckBox" Content="Администратор терминала"/>
|
||||||
<TextBlock Text="Выберите сотрудника справа и нажмите «Зарегистрировать отпечаток». Сканер попросит приложить один палец три раза." TextWrapping="Wrap" Opacity="0.75"/>
|
<TextBlock Text="Выберите сотрудника справа и нажмите «Добавить отпечаток». Для каждого нового пальца повторите регистрацию из трёх сканов." TextWrapping="Wrap" Opacity="0.75"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<ListView x:Name="EmployeesList" Grid.Column="1" SelectionMode="Single">
|
<ListView x:Name="EmployeesList" Grid.Column="1" Background="{ThemeResource KronaRaisedSurfaceBrush}" SelectionMode="Single">
|
||||||
<ListView.ItemTemplate>
|
<ListView.ItemTemplate>
|
||||||
<DataTemplate x:DataType="models:EmployeeRecord">
|
<DataTemplate x:DataType="models:EmployeeRecord">
|
||||||
<Grid Padding="4,10" ColumnSpacing="14">
|
<Grid Padding="4,10" ColumnSpacing="14">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="90"/>
|
<ColumnDefinition Width="90"/>
|
||||||
<ColumnDefinition Width="*"/>
|
<ColumnDefinition Width="*"/>
|
||||||
<ColumnDefinition Width="150"/>
|
<ColumnDefinition Width="130"/>
|
||||||
<ColumnDefinition Width="140"/>
|
<ColumnDefinition Width="90"/>
|
||||||
<ColumnDefinition Width="210"/>
|
<ColumnDefinition Width="130"/>
|
||||||
|
<ColumnDefinition Width="170"/>
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Text="{x:Bind PersonnelNumber}"/>
|
<TextBlock Text="{x:Bind PersonnelNumber}"/>
|
||||||
<TextBlock Grid.Column="1" Text="{x:Bind DisplayName}" FontWeight="SemiBold"/>
|
<TextBlock Grid.Column="1" Text="{x:Bind DisplayName}" FontWeight="SemiBold"/>
|
||||||
<TextBlock Grid.Column="2" Text="{x:Bind Position}"/>
|
<TextBlock Grid.Column="2" Text="{x:Bind Position}"/>
|
||||||
<TextBlock Grid.Column="3" Text="{x:Bind AccessStatus}"/>
|
<TextBlock Grid.Column="3" Text="{x:Bind GenderDisplay}"/>
|
||||||
<TextBlock Grid.Column="4" Text="{x:Bind FingerprintStatus}"/>
|
<TextBlock Grid.Column="4" Text="{x:Bind AccessStatus}"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="{x:Bind FingerprintStatus}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListView.ItemTemplate>
|
</ListView.ItemTemplate>
|
||||||
|
|
@ -113,10 +183,10 @@
|
||||||
</Grid>
|
</Grid>
|
||||||
</TabViewItem>
|
</TabViewItem>
|
||||||
|
|
||||||
<TabViewItem Header="Уведомления и сканер" IsClosable="False">
|
<TabViewItem x:Name="SettingsTab" Header="Уведомления и сканер" IsClosable="False">
|
||||||
<ScrollViewer>
|
<ScrollViewer>
|
||||||
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
|
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
|
||||||
<TextBlock Text="Сканер" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
<TextBlock Text="Сканер" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||||
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
|
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
|
||||||
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
|
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
|
||||||
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
|
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
|
||||||
|
|
@ -131,8 +201,12 @@
|
||||||
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
|
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
|
||||||
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
|
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<Button Content="Сохранить настройки" Click="SaveSettings_Click"/>
|
<Button Content="Сохранить настройки"
|
||||||
|
Background="{ThemeResource KronaPrimaryBrush}"
|
||||||
|
Foreground="{ThemeResource KronaTextInverseBrush}"
|
||||||
|
Click="SaveSettings_Click"/>
|
||||||
<Button Content="Отправить тест" Click="TestNotification_Click"/>
|
<Button Content="Отправить тест" Click="TestNotification_Click"/>
|
||||||
|
<Button Content="Заблокировать" Click="LogoutAdmin_Click"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
|
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using KronaStaffTerminal.Models;
|
using KronaStaffTerminal.Models;
|
||||||
using KronaStaffTerminal.Services;
|
using KronaStaffTerminal.Services;
|
||||||
|
using Microsoft.UI.Dispatching;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
|
||||||
|
|
@ -14,6 +15,12 @@ public sealed partial class MainPage : Page
|
||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
private TaskCompletionSource<bool>? _adminAuthCompletion;
|
private TaskCompletionSource<bool>? _adminAuthCompletion;
|
||||||
private ContentDialog? _adminAuthDialog;
|
private ContentDialog? _adminAuthDialog;
|
||||||
|
private readonly DispatcherQueueTimer _adminSessionTimer;
|
||||||
|
private bool _adminSessionActive;
|
||||||
|
private bool _adminNavigationInProgress;
|
||||||
|
private string? _registrationEmployeeId;
|
||||||
|
private DateTimeOffset _adminSessionExpiresAtUtc;
|
||||||
|
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
public MainPage()
|
public MainPage()
|
||||||
{
|
{
|
||||||
|
|
@ -22,6 +29,17 @@ public sealed partial class MainPage : Page
|
||||||
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
|
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
|
||||||
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
|
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
|
||||||
_scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score));
|
_scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score));
|
||||||
|
_adminSessionTimer = DispatcherQueue.CreateTimer();
|
||||||
|
_adminSessionTimer.Interval = TimeSpan.FromSeconds(15);
|
||||||
|
_adminSessionTimer.IsRepeating = true;
|
||||||
|
_adminSessionTimer.Tick += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_adminSessionActive && DateTimeOffset.UtcNow >= _adminSessionExpiresAtUtc)
|
||||||
|
{
|
||||||
|
LockAdminSession("Сеанс администратора завершён после 5 минут бездействия.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_adminSessionTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void Page_Loaded(object sender, RoutedEventArgs e)
|
private async void Page_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
|
@ -33,9 +51,118 @@ public sealed partial class MainPage : Page
|
||||||
ApplyStartupSetting();
|
ApplyStartupSetting();
|
||||||
RefreshLists();
|
RefreshLists();
|
||||||
ConnectScanner();
|
ConnectScanner();
|
||||||
|
MainTabs.SelectedItem = TerminalTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Page_Unloaded(object sender, RoutedEventArgs e) => _scanner.Dispose();
|
private void Page_Unloaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_adminSessionTimer.Stop();
|
||||||
|
_scanner.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void MainTabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_adminNavigationInProgress || ReferenceEquals(MainTabs.SelectedItem, TerminalTab)) return;
|
||||||
|
if (HasActiveAdminSession)
|
||||||
|
{
|
||||||
|
TouchAdminSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedTab = MainTabs.SelectedItem;
|
||||||
|
_adminNavigationInProgress = true;
|
||||||
|
MainTabs.SelectedItem = TerminalTab;
|
||||||
|
var authorized = await EnsureAdminSessionAsync("вход в административный раздел");
|
||||||
|
if (authorized)
|
||||||
|
{
|
||||||
|
MainTabs.SelectedItem = requestedTab;
|
||||||
|
}
|
||||||
|
_adminNavigationInProgress = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogoutAdmin_Click(object sender, RoutedEventArgs e) =>
|
||||||
|
LockAdminSession("Административный раздел заблокирован.");
|
||||||
|
|
||||||
|
public void LockAdminSession() =>
|
||||||
|
LockAdminSession("Административный раздел заблокирован при сворачивании.");
|
||||||
|
|
||||||
|
private void LockAdminSession(string message)
|
||||||
|
{
|
||||||
|
_adminSessionActive = false;
|
||||||
|
_adminSessionExpiresAtUtc = default;
|
||||||
|
_adminAuthCompletion?.TrySetResult(false);
|
||||||
|
_adminAuthDialog?.Hide();
|
||||||
|
if (MainTabs is not null)
|
||||||
|
{
|
||||||
|
_adminNavigationInProgress = true;
|
||||||
|
MainTabs.SelectedItem = TerminalTab;
|
||||||
|
_adminNavigationInProgress = false;
|
||||||
|
}
|
||||||
|
if (_loaded) SetStatus(message, InfoBarSeverity.Informational);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasActiveAdminSession =>
|
||||||
|
_adminSessionActive && DateTimeOffset.UtcNow < _adminSessionExpiresAtUtc;
|
||||||
|
|
||||||
|
private void TouchAdminSession() =>
|
||||||
|
_adminSessionExpiresAtUtc = DateTimeOffset.UtcNow.Add(AdminSessionDuration);
|
||||||
|
|
||||||
|
private async Task<bool> EnsureAdminSessionAsync(string operation)
|
||||||
|
{
|
||||||
|
if (HasActiveAdminSession)
|
||||||
|
{
|
||||||
|
TouchAdminSession();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorized = !PinConfigured && !AdministratorFingerprintConfigured
|
||||||
|
? await CreateInitialAdminPinAsync()
|
||||||
|
: await AuthorizeAdminAsync(operation);
|
||||||
|
if (!authorized) return false;
|
||||||
|
|
||||||
|
_adminSessionActive = true;
|
||||||
|
TouchAdminSession();
|
||||||
|
SetStatus(AdministratorFingerprintConfigured
|
||||||
|
? "Вход администратора выполнен. Сеанс закроется через 5 минут бездействия."
|
||||||
|
: "Первичная настройка открыта. Добавьте администратора и зарегистрируйте его отпечаток.",
|
||||||
|
InfoBarSeverity.Success);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CreateInitialAdminPinAsync()
|
||||||
|
{
|
||||||
|
var pinBox = new PasswordBox { Header = "Придумайте PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek };
|
||||||
|
var repeatBox = new PasswordBox { Header = "Повторите PIN", PasswordRevealMode = PasswordRevealMode.Peek };
|
||||||
|
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||||||
|
content.Children.Add(new TextBlock
|
||||||
|
{
|
||||||
|
Text = "Первичная настройка. После входа добавьте сотрудника-администратора и зарегистрируйте его отпечаток.",
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
});
|
||||||
|
content.Children.Add(pinBox);
|
||||||
|
content.Children.Add(repeatBox);
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
XamlRoot = XamlRoot,
|
||||||
|
Title = "Создание администратора",
|
||||||
|
Content = content,
|
||||||
|
PrimaryButtonText = "Создать PIN",
|
||||||
|
CloseButtonText = "Отмена",
|
||||||
|
DefaultButton = ContentDialogButton.Primary
|
||||||
|
};
|
||||||
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return false;
|
||||||
|
if (pinBox.Password.Length < 6 || pinBox.Password != repeatBox.Password)
|
||||||
|
{
|
||||||
|
SetStatus("PIN должен содержать не менее 6 символов, оба значения должны совпадать.", InfoBarSeverity.Error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pin = AdminPinService.Hash(pinBox.Password);
|
||||||
|
_state.Settings.AdminPinSalt = pin.Salt;
|
||||||
|
_state.Settings.AdminPinHash = pin.Hash;
|
||||||
|
await _store.SaveAsync(_state);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void ConnectScanner()
|
private void ConnectScanner()
|
||||||
{
|
{
|
||||||
|
|
@ -43,14 +170,29 @@ public sealed partial class MainPage : Page
|
||||||
{
|
{
|
||||||
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
|
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
|
||||||
.Where(employee => employee.HasFingerprint)
|
.Where(employee => employee.HasFingerprint)
|
||||||
.Select(employee => (employee.FingerprintId, employee.FingerprintTemplate!)));
|
.SelectMany(employee => employee.Fingerprints)
|
||||||
|
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
|
||||||
ReaderCombo.Items.Clear();
|
ReaderCombo.Items.Clear();
|
||||||
for (var index = 0; index < _scanner.DeviceCount; index++)
|
for (var index = 0; index < _scanner.DeviceCount; index++)
|
||||||
{
|
{
|
||||||
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
|
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
|
||||||
}
|
}
|
||||||
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
|
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
|
||||||
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
|
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
|
||||||
|
{
|
||||||
|
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
|
||||||
|
_ = _store.SaveAsync(_state);
|
||||||
|
}
|
||||||
|
if (_scanner.SkippedTemplateCount > 0)
|
||||||
|
{
|
||||||
|
SetStatus(
|
||||||
|
$"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
|
||||||
|
InfoBarSeverity.Warning);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|
@ -60,6 +202,7 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async void AddEmployee_Click(object sender, RoutedEventArgs e)
|
private async void AddEmployee_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("добавление сотрудника")) return;
|
||||||
var name = EmployeeNameBox.Text.Trim();
|
var name = EmployeeNameBox.Text.Trim();
|
||||||
var number = PersonnelNumberBox.Text.Trim();
|
var number = PersonnelNumberBox.Text.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
|
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
|
||||||
|
|
@ -83,6 +226,12 @@ public sealed partial class MainPage : Page
|
||||||
DisplayName = name,
|
DisplayName = name,
|
||||||
PersonnelNumber = number,
|
PersonnelNumber = number,
|
||||||
Position = PositionBox.Text.Trim(),
|
Position = PositionBox.Text.Trim(),
|
||||||
|
Gender = GenderComboBox.SelectedIndex switch
|
||||||
|
{
|
||||||
|
1 => "Мужской",
|
||||||
|
2 => "Женский",
|
||||||
|
_ => ""
|
||||||
|
},
|
||||||
IsAdministrator = AdministratorCheckBox.IsChecked == true
|
IsAdministrator = AdministratorCheckBox.IsChecked == true
|
||||||
};
|
};
|
||||||
_state.Employees.Add(employee);
|
_state.Employees.Add(employee);
|
||||||
|
|
@ -90,6 +239,7 @@ public sealed partial class MainPage : Page
|
||||||
EmployeeNameBox.Text = "";
|
EmployeeNameBox.Text = "";
|
||||||
PersonnelNumberBox.Text = "";
|
PersonnelNumberBox.Text = "";
|
||||||
PositionBox.Text = "";
|
PositionBox.Text = "";
|
||||||
|
GenderComboBox.SelectedIndex = 0;
|
||||||
AdministratorCheckBox.IsChecked = false;
|
AdministratorCheckBox.IsChecked = false;
|
||||||
RefreshLists();
|
RefreshLists();
|
||||||
EmployeesList.SelectedItem = employee;
|
EmployeesList.SelectedItem = employee;
|
||||||
|
|
@ -98,6 +248,7 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
|
private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
|
||||||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||||
{
|
{
|
||||||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||||
|
|
@ -108,31 +259,68 @@ public sealed partial class MainPage : Page
|
||||||
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
|
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (employee.HasFingerprint && SecurityConfigured &&
|
var fingerprintId = _state.Employees
|
||||||
!await AuthorizeAdminAsync("замена зарегистрированного отпечатка"))
|
.SelectMany(item => item.Fingerprints)
|
||||||
{
|
.Select(item => item.Id)
|
||||||
return;
|
.DefaultIfEmpty(0)
|
||||||
}
|
.Max() + 1;
|
||||||
if (employee.FingerprintId <= 0)
|
_registrationEmployeeId = employee.Id;
|
||||||
{
|
_scanner.BeginRegistration(fingerprintId);
|
||||||
employee.FingerprintId = _state.Employees.Select(item => item.FingerprintId).DefaultIfEmpty(0).Max() + 1;
|
|
||||||
}
|
|
||||||
_scanner.BeginRegistration(employee.FingerprintId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
|
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
|
||||||
{
|
{
|
||||||
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
|
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
|
||||||
if (employee is null) return;
|
if (employee is null) return;
|
||||||
employee.FingerprintTemplate = template;
|
employee.Fingerprints.Add(new FingerprintRecord
|
||||||
|
{
|
||||||
|
Id = fingerprintId,
|
||||||
|
Template = template,
|
||||||
|
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
|
||||||
|
});
|
||||||
|
_registrationEmployeeId = null;
|
||||||
await _store.SaveAsync(_state);
|
await _store.SaveAsync(_state);
|
||||||
RefreshLists();
|
RefreshLists();
|
||||||
TerminalMessage.Text = $"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
|
TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
|
||||||
SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
|
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("удаление отпечатков")) return;
|
||||||
|
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||||
|
{
|
||||||
|
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!employee.HasFingerprint)
|
||||||
|
{
|
||||||
|
SetStatus("У выбранного сотрудника нет зарегистрированных отпечатков.", InfoBarSeverity.Informational);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
XamlRoot = XamlRoot,
|
||||||
|
Title = "Удалить все отпечатки сотрудника?",
|
||||||
|
Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.",
|
||||||
|
PrimaryButtonText = "Удалить отпечатки",
|
||||||
|
CloseButtonText = "Отмена",
|
||||||
|
DefaultButton = ContentDialogButton.Close
|
||||||
|
};
|
||||||
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||||
|
if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return;
|
||||||
|
|
||||||
|
employee.Fingerprints.Clear();
|
||||||
|
await _store.SaveAsync(_state);
|
||||||
|
RefreshLists();
|
||||||
|
ConnectScanner();
|
||||||
|
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e)
|
private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("изменение прав администратора")) return;
|
||||||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||||
{
|
{
|
||||||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||||
|
|
@ -152,6 +340,7 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
|
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("удаление сотрудника")) return;
|
||||||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||||
{
|
{
|
||||||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||||
|
|
@ -169,7 +358,7 @@ public sealed partial class MainPage : Page
|
||||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||||
if (!SecurityConfigured)
|
if (!SecurityConfigured)
|
||||||
{
|
{
|
||||||
SetStatus("Сначала назначьте администратора с отпечатком и установите PIN.", InfoBarSeverity.Warning);
|
SetStatus("Сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
|
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
|
||||||
|
|
@ -188,7 +377,8 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async Task HandleRecognizedFingerAsync(int fingerprintId, int score)
|
private async Task HandleRecognizedFingerAsync(int fingerprintId, int score)
|
||||||
{
|
{
|
||||||
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
|
var employee = _state.Employees.FirstOrDefault(item =>
|
||||||
|
item.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId));
|
||||||
if (employee is null) return;
|
if (employee is null) return;
|
||||||
if (_adminAuthCompletion is not null)
|
if (_adminAuthCompletion is not null)
|
||||||
{
|
{
|
||||||
|
|
@ -250,68 +440,99 @@ public sealed partial class MainPage : Page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void ClearAttendance_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("очистка журнала")) return;
|
||||||
|
var dialog = new ContentDialog
|
||||||
|
{
|
||||||
|
XamlRoot = XamlRoot,
|
||||||
|
Title = "Очистить журнал отметок?",
|
||||||
|
Content = $"Будут удалены все записи прихода и ухода: {_state.Attendance.Count}. Сотрудники и отпечатки останутся.",
|
||||||
|
PrimaryButtonText = "Очистить журнал",
|
||||||
|
CloseButtonText = "Отмена",
|
||||||
|
DefaultButton = ContentDialogButton.Close
|
||||||
|
};
|
||||||
|
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||||
|
if (!await AuthorizeAdminAsync("очистка журнала прихода и ухода")) return;
|
||||||
|
|
||||||
|
_state.Attendance.Clear();
|
||||||
|
await _store.SaveAsync(_state);
|
||||||
|
RefreshLists();
|
||||||
|
TerminalMessage.Text = "Журнал отметок очищен";
|
||||||
|
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> AuthorizeExitAsync()
|
public async Task<bool> AuthorizeExitAsync()
|
||||||
{
|
{
|
||||||
if (!SecurityConfigured)
|
if (!SecurityConfigured)
|
||||||
{
|
{
|
||||||
SetStatus("Выход заблокирован: сначала настройте PIN и отпечаток администратора.", InfoBarSeverity.Warning);
|
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return await AuthorizeAdminAsync("полное завершение терминала");
|
return await AuthorizeAdminAsync("полное завершение терминала");
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool SecurityConfigured =>
|
private bool PinConfigured =>
|
||||||
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) &&
|
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash);
|
||||||
|
|
||||||
|
private bool AdministratorFingerprintConfigured =>
|
||||||
_state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
|
_state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
|
||||||
|
|
||||||
|
private bool SecurityConfigured =>
|
||||||
|
PinConfigured || AdministratorFingerprintConfigured;
|
||||||
|
|
||||||
private async Task<bool> AuthorizeAdminAsync(string operation)
|
private async Task<bool> AuthorizeAdminAsync(string operation)
|
||||||
{
|
{
|
||||||
var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280 };
|
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
|
||||||
var pinDialog = new ContentDialog
|
var pinBox = new PasswordBox
|
||||||
{
|
{
|
||||||
XamlRoot = XamlRoot,
|
Header = "PIN администратора",
|
||||||
Title = $"Подтверждение: {operation}",
|
PasswordRevealMode = PasswordRevealMode.Peek,
|
||||||
Content = pinBox,
|
MinWidth = 280
|
||||||
PrimaryButtonText = "Продолжить",
|
|
||||||
CloseButtonText = "Отмена",
|
|
||||||
DefaultButton = ContentDialogButton.Primary
|
|
||||||
};
|
};
|
||||||
if (await pinDialog.ShowAsync() != ContentDialogResult.Primary) return false;
|
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||||||
if (!AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash))
|
content.Children.Add(new TextBlock
|
||||||
{
|
{
|
||||||
SetStatus("Неверный PIN администратора.", InfoBarSeverity.Error);
|
Text = hasAdministratorFingerprint
|
||||||
return false;
|
? "Введите PIN или приложите зарегистрированный палец администратора."
|
||||||
}
|
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
|
||||||
|
TextWrapping = TextWrapping.Wrap
|
||||||
|
});
|
||||||
|
content.Children.Add(pinBox);
|
||||||
|
|
||||||
_adminAuthCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
_adminAuthCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
_adminAuthDialog = new ContentDialog
|
_adminAuthDialog = new ContentDialog
|
||||||
{
|
{
|
||||||
XamlRoot = XamlRoot,
|
XamlRoot = XamlRoot,
|
||||||
Title = "Приложите палец администратора",
|
Title = $"Вход администратора: {operation}",
|
||||||
Content = "PIN принят. Теперь приложите зарегистрированный палец сотрудника с правами администратора.",
|
Content = content,
|
||||||
|
PrimaryButtonText = "Войти по PIN",
|
||||||
CloseButtonText = "Отмена",
|
CloseButtonText = "Отмена",
|
||||||
DefaultButton = ContentDialogButton.Close
|
DefaultButton = ContentDialogButton.Primary
|
||||||
};
|
};
|
||||||
_ = ObserveAdminDialogAsync(_adminAuthDialog, _adminAuthCompletion);
|
var dialogResult = await _adminAuthDialog.ShowAsync();
|
||||||
var authorized = await _adminAuthCompletion.Task;
|
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
|
||||||
|
_adminAuthCompletion.Task.Result;
|
||||||
|
_adminAuthCompletion.TrySetResult(false);
|
||||||
|
var authorized = fingerprintAuthorized ||
|
||||||
|
(dialogResult == ContentDialogResult.Primary &&
|
||||||
|
AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash));
|
||||||
_adminAuthCompletion = null;
|
_adminAuthCompletion = null;
|
||||||
_adminAuthDialog = null;
|
_adminAuthDialog = null;
|
||||||
SetStatus(authorized ? "Действие подтверждено администратором." : "Подтверждение отменено.", authorized ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
SetStatus(authorized
|
||||||
|
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
|
||||||
|
: dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.",
|
||||||
|
authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning);
|
||||||
return authorized;
|
return authorized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ObserveAdminDialogAsync(ContentDialog dialog, TaskCompletionSource<bool> completion)
|
|
||||||
{
|
|
||||||
await dialog.ShowAsync();
|
|
||||||
completion.TrySetResult(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void SetAdminPin_Click(object sender, RoutedEventArgs e)
|
private async void SetAdminPin_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("изменение PIN")) return;
|
||||||
var newPin = NewAdminPinBox.Password.Trim();
|
var newPin = NewAdminPinBox.Password.Trim();
|
||||||
if (newPin.Length < 4)
|
if (newPin.Length < 6)
|
||||||
{
|
{
|
||||||
SetStatus("PIN должен содержать не менее 4 символов.", InfoBarSeverity.Warning);
|
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return;
|
if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return;
|
||||||
|
|
@ -325,6 +546,7 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
|
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
|
||||||
CopySettingsFromControls();
|
CopySettingsFromControls();
|
||||||
await _store.SaveAsync(_state);
|
await _store.SaveAsync(_state);
|
||||||
ApplyStartupSetting();
|
ApplyStartupSetting();
|
||||||
|
|
@ -334,6 +556,7 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
private async void TestNotification_Click(object sender, RoutedEventArgs e)
|
private async void TestNotification_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
|
||||||
CopySettingsFromControls();
|
CopySettingsFromControls();
|
||||||
var result = await _notifications.SendTestAsync(_state.Settings);
|
var result = await _notifications.SendTestAsync(_state.Settings);
|
||||||
SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ public sealed partial class MainWindow : Window
|
||||||
AppWindow.SetIcon("Assets/AppIcon.ico");
|
AppWindow.SetIcon("Assets/AppIcon.ico");
|
||||||
AppWindow.Resize(new Windows.Graphics.SizeInt32(1120, 760));
|
AppWindow.Resize(new Windows.Graphics.SizeInt32(1120, 760));
|
||||||
AppWindow.Closing += AppWindow_Closing;
|
AppWindow.Closing += AppWindow_Closing;
|
||||||
|
AppWindow.Changed += AppWindow_Changed;
|
||||||
|
|
||||||
// Navigate the root frame to the main page on startup.
|
// Navigate the root frame to the main page on startup.
|
||||||
RootFrame.Navigate(typeof(MainPage));
|
RootFrame.Navigate(typeof(MainPage));
|
||||||
|
|
@ -33,10 +34,23 @@ public sealed partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
if (_allowClose) return;
|
if (_allowClose) return;
|
||||||
args.Cancel = true;
|
args.Cancel = true;
|
||||||
|
if (RootFrame.Content is MainPage page) page.LockAdminSession();
|
||||||
AppWindow.Hide();
|
AppWindow.Hide();
|
||||||
((App)Application.Current).NotifyMinimizedToTray();
|
((App)Application.Current).NotifyMinimizedToTray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AppWindow_Changed(Microsoft.UI.Windowing.AppWindow sender, Microsoft.UI.Windowing.AppWindowChangedEventArgs args)
|
||||||
|
{
|
||||||
|
if (!args.DidPresenterChange ||
|
||||||
|
sender.Presenter is not Microsoft.UI.Windowing.OverlappedPresenter presenter ||
|
||||||
|
presenter.State != Microsoft.UI.Windowing.OverlappedPresenterState.Minimized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RootFrame.Content is MainPage page) page.LockAdminSession();
|
||||||
|
}
|
||||||
|
|
||||||
public void ShowWindow()
|
public void ShowWindow()
|
||||||
{
|
{
|
||||||
AppWindow.Show();
|
AppWindow.Show();
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,38 @@ public sealed class EmployeeRecord
|
||||||
public string PersonnelNumber { get; set; } = "";
|
public string PersonnelNumber { get; set; } = "";
|
||||||
public string DisplayName { get; set; } = "";
|
public string DisplayName { get; set; } = "";
|
||||||
public string Position { get; set; } = "";
|
public string Position { get; set; } = "";
|
||||||
|
public string Gender { get; set; } = "";
|
||||||
public bool IsAdministrator { get; set; }
|
public bool IsAdministrator { get; set; }
|
||||||
|
public List<FingerprintRecord> Fingerprints { get; set; } = [];
|
||||||
|
|
||||||
|
// Legacy single-fingerprint fields are kept for one-way migration of existing encrypted state.
|
||||||
public int FingerprintId { get; set; }
|
public int FingerprintId { get; set; }
|
||||||
public string? FingerprintTemplate { get; set; }
|
public string? FingerprintTemplate { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public bool HasFingerprint => FingerprintId > 0 && !string.IsNullOrWhiteSpace(FingerprintTemplate);
|
public bool HasFingerprint => Fingerprints.Count > 0;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string FingerprintStatus => HasFingerprint ? "Отпечаток зарегистрирован" : "Отпечатка нет";
|
public string FingerprintStatus => Fingerprints.Count switch
|
||||||
|
{
|
||||||
|
0 => "Отпечатков нет",
|
||||||
|
1 => "1 отпечаток",
|
||||||
|
2 or 3 or 4 => $"{Fingerprints.Count} отпечатка",
|
||||||
|
_ => $"{Fingerprints.Count} отпечатков"
|
||||||
|
};
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string AccessStatus => IsAdministrator ? "Администратор" : "Сотрудник";
|
public string AccessStatus => IsAdministrator ? "Администратор" : "Сотрудник";
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string GenderDisplay => string.IsNullOrWhiteSpace(Gender) ? "—" : Gender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FingerprintRecord
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Template { get; set; } = "";
|
||||||
|
public string Label { get; set; } = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AttendanceRecord
|
public sealed class AttendanceRecord
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,17 @@
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- выбор одного из подключённых сканеров;
|
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
|
||||||
- добавление сотрудников с должностью и правами администратора;
|
- добавление сотрудников с должностью, полом и правами администратора;
|
||||||
- регистрация отпечатка по трём последовательным сканам;
|
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
|
||||||
- чередование событий «Приход» и «Уход»;
|
- чередование событий «Приход» и «Уход»;
|
||||||
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
|
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
|
||||||
- локальный журнал последних отметок;
|
- локальный журнал последних отметок;
|
||||||
|
- защищённая очистка журнала без удаления сотрудников и отпечатков;
|
||||||
- уведомления через Telegram Bot API и MAX Bot API;
|
- уведомления через Telegram Bot API и MAX Bot API;
|
||||||
- автозагрузка вместе с Windows и постоянная работа в системном трее;
|
- автозагрузка вместе с Windows и постоянная работа в системном трее;
|
||||||
- удаление сотрудника и завершение терминала только по PIN и отпечатку администратора;
|
- закрытый административный раздел с входом по PIN или отпечатку администратора;
|
||||||
|
- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
|
||||||
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
|
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
|
||||||
|
|
||||||
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
|
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
|
||||||
|
|
@ -27,20 +29,21 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
|
||||||
|
|
||||||
## Первый сотрудник
|
## Первый сотрудник
|
||||||
|
|
||||||
1. Откройте вкладку «Сотрудники».
|
1. Откройте вкладку «Сотрудники» и при первом входе создайте PIN длиной не менее шести символов.
|
||||||
2. Введите табельный номер и имя, затем нажмите «Добавить».
|
2. Введите табельный номер и имя, отметьте «Администратор терминала», затем нажмите «Добавить».
|
||||||
3. Выберите сотрудника в списке и нажмите «Зарегистрировать отпечаток».
|
3. Выберите сотрудника в списке и нажмите «Добавить отпечаток».
|
||||||
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
|
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
|
||||||
|
5. Для регистрации другого пальца повторно нажмите «Добавить отпечаток».
|
||||||
|
|
||||||
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход». Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
|
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход». Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
|
||||||
|
|
||||||
## Администратор и работа в фоне
|
## Администратор и работа в фоне
|
||||||
|
|
||||||
1. Выберите сотрудника и нажмите «Права администратора».
|
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
|
||||||
2. Убедитесь, что у него зарегистрирован отпечаток.
|
2. Для входа достаточно PIN либо зарегистрированного отпечатка администратора.
|
||||||
3. На вкладке настроек задайте PIN длиной не менее четырёх символов.
|
3. Кнопка «Заблокировать», сворачивание окна или пять минут бездействия завершают административный сеанс.
|
||||||
|
|
||||||
После первичной настройки удаление сотрудника, изменение прав, изменение PIN и полный выход требуют сначала PIN, затем отпечаток администратора. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
|
После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
|
||||||
|
|
||||||
## Уведомления
|
## Уведомления
|
||||||
|
|
||||||
|
|
|
||||||
24
apps/KronaStaffTerminal/Services/CrashLogService.cs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
namespace KronaStaffTerminal.Services;
|
||||||
|
|
||||||
|
public static class CrashLogService
|
||||||
|
{
|
||||||
|
private static readonly string LogDirectory = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"Krona",
|
||||||
|
"StaffTerminal",
|
||||||
|
"logs");
|
||||||
|
|
||||||
|
public static void Write(Exception exception)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(LogDirectory);
|
||||||
|
var entry = $"[{DateTimeOffset.Now:O}]{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
|
||||||
|
File.AppendAllText(Path.Combine(LogDirectory, "crash.log"), entry);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Logging must never replace the original application failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,11 +22,13 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
|
|
||||||
public int DeviceCount { get; private set; }
|
public int DeviceCount { get; private set; }
|
||||||
public int ReaderIndex { get; private set; }
|
public int ReaderIndex { get; private set; }
|
||||||
|
public int SkippedTemplateCount { get; private set; }
|
||||||
public bool IsConnected => _device != IntPtr.Zero;
|
public bool IsConnected => _device != IntPtr.Zero;
|
||||||
|
|
||||||
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
|
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
|
||||||
{
|
{
|
||||||
Disconnect();
|
Disconnect();
|
||||||
|
SkippedTemplateCount = 0;
|
||||||
var result = zkfp2.Init();
|
var result = zkfp2.Init();
|
||||||
if (result != 0)
|
if (result != 0)
|
||||||
{
|
{
|
||||||
|
|
@ -40,12 +42,21 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
throw new InvalidOperationException("Подключённые сканеры не найдены.");
|
throw new InvalidOperationException("Подключённые сканеры не найдены.");
|
||||||
}
|
}
|
||||||
|
|
||||||
ReaderIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
|
var preferredIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
|
||||||
_device = zkfp2.OpenDevice(ReaderIndex);
|
var candidateIndexes = new[] { preferredIndex }
|
||||||
|
.Concat(Enumerable.Range(0, DeviceCount).Where(index => index != preferredIndex));
|
||||||
|
foreach (var candidateIndex in candidateIndexes)
|
||||||
|
{
|
||||||
|
var candidateDevice = zkfp2.OpenDevice(candidateIndex);
|
||||||
|
if (candidateDevice == IntPtr.Zero) continue;
|
||||||
|
ReaderIndex = candidateIndex;
|
||||||
|
_device = candidateDevice;
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (_device == IntPtr.Zero)
|
if (_device == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
zkfp2.Terminate();
|
zkfp2.Terminate();
|
||||||
throw new InvalidOperationException($"Не удалось открыть сканер №{ReaderIndex + 1}.");
|
throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_database = zkfp2.DBInit();
|
_database = zkfp2.DBInit();
|
||||||
|
|
@ -59,8 +70,18 @@ public sealed class FingerprintScanner : IDisposable
|
||||||
|
|
||||||
foreach (var item in templates)
|
foreach (var item in templates)
|
||||||
{
|
{
|
||||||
var template = zkfp2.Base64ToBlob(item.Template);
|
try
|
||||||
zkfp2.DBAdd(_database, item.FingerprintId, template);
|
{
|
||||||
|
var template = Convert.FromBase64String(item.Template);
|
||||||
|
if (template.Length == 0 || zkfp2.DBAdd(_database, item.FingerprintId, template) != 0)
|
||||||
|
{
|
||||||
|
SkippedTemplateCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
SkippedTemplateCount++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_captureCancellation = new CancellationTokenSource();
|
_captureCancellation = new CancellationTokenSource();
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,8 @@ public sealed class TerminalStore
|
||||||
var encrypted = await File.ReadAllBytesAsync(sourcePath);
|
var encrypted = await File.ReadAllBytesAsync(sourcePath);
|
||||||
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||||
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions) ?? new TerminalState();
|
var state = JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions) ?? new TerminalState();
|
||||||
if (!string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
var migrated = MigrateFingerprints(state);
|
||||||
|
if (migrated || !string.Equals(sourcePath, _filePath, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await SaveAsync(state);
|
await SaveAsync(state);
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +36,33 @@ public sealed class TerminalStore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool MigrateFingerprints(TerminalState state)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
foreach (var employee in state.Employees)
|
||||||
|
{
|
||||||
|
employee.Fingerprints ??= [];
|
||||||
|
if (employee.FingerprintId <= 0 || string.IsNullOrWhiteSpace(employee.FingerprintTemplate))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee.Fingerprints.All(item => item.Id != employee.FingerprintId))
|
||||||
|
{
|
||||||
|
employee.Fingerprints.Add(new FingerprintRecord
|
||||||
|
{
|
||||||
|
Id = employee.FingerprintId,
|
||||||
|
Template = employee.FingerprintTemplate,
|
||||||
|
Label = "Отпечаток 1"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
employee.FingerprintId = 0;
|
||||||
|
employee.FingerprintTemplate = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SaveAsync(TerminalState state)
|
public async Task SaveAsync(TerminalState state)
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(_filePath)!;
|
var directory = Path.GetDirectoryName(_filePath)!;
|
||||||
|
|
|
||||||
53
apps/KronaStaffTerminal/Styles/KronaTheme.xaml
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ResourceDictionary
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
|
<ResourceDictionary.ThemeDictionaries>
|
||||||
|
<ResourceDictionary x:Key="Default">
|
||||||
|
<SolidColorBrush x:Key="KronaAppBackgroundBrush" Color="#EFE3CC" />
|
||||||
|
<SolidColorBrush x:Key="KronaWorkspaceBrush" Color="#F3EAD7" />
|
||||||
|
<SolidColorBrush x:Key="KronaSurfaceBrush" Color="#F8F2E6" />
|
||||||
|
<SolidColorBrush x:Key="KronaRaisedSurfaceBrush" Color="#FCF8EF" />
|
||||||
|
<SolidColorBrush x:Key="KronaSurfaceMutedBrush" Color="#E9DDC5" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextPrimaryBrush" Color="#302A23" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextSecondaryBrush" Color="#756A5B" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextInverseBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="KronaBorderBrush" Color="#D9C9AC" />
|
||||||
|
<SolidColorBrush x:Key="KronaPrimaryBrush" Color="#9A743D" />
|
||||||
|
<SolidColorBrush x:Key="KronaPrimarySoftBrush" Color="#EADBC1" />
|
||||||
|
<SolidColorBrush x:Key="KronaGoldBrush" Color="#B78335" />
|
||||||
|
<SolidColorBrush x:Key="KronaGoldSoftBrush" Color="#EFE0C2" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
<ResourceDictionary x:Key="Light">
|
||||||
|
<SolidColorBrush x:Key="KronaAppBackgroundBrush" Color="#EFE3CC" />
|
||||||
|
<SolidColorBrush x:Key="KronaWorkspaceBrush" Color="#F3EAD7" />
|
||||||
|
<SolidColorBrush x:Key="KronaSurfaceBrush" Color="#F8F2E6" />
|
||||||
|
<SolidColorBrush x:Key="KronaRaisedSurfaceBrush" Color="#FCF8EF" />
|
||||||
|
<SolidColorBrush x:Key="KronaSurfaceMutedBrush" Color="#E9DDC5" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextPrimaryBrush" Color="#302A23" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextSecondaryBrush" Color="#756A5B" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextInverseBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="KronaBorderBrush" Color="#D9C9AC" />
|
||||||
|
<SolidColorBrush x:Key="KronaPrimaryBrush" Color="#9A743D" />
|
||||||
|
<SolidColorBrush x:Key="KronaPrimarySoftBrush" Color="#EADBC1" />
|
||||||
|
<SolidColorBrush x:Key="KronaGoldBrush" Color="#B78335" />
|
||||||
|
<SolidColorBrush x:Key="KronaGoldSoftBrush" Color="#EFE0C2" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
<ResourceDictionary x:Key="Dark">
|
||||||
|
<SolidColorBrush x:Key="KronaAppBackgroundBrush" Color="#171A18" />
|
||||||
|
<SolidColorBrush x:Key="KronaWorkspaceBrush" Color="#1D211E" />
|
||||||
|
<SolidColorBrush x:Key="KronaSurfaceBrush" Color="#252A26" />
|
||||||
|
<SolidColorBrush x:Key="KronaRaisedSurfaceBrush" Color="#2B312C" />
|
||||||
|
<SolidColorBrush x:Key="KronaSurfaceMutedBrush" Color="#212621" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextPrimaryBrush" Color="#EEE7DA" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextSecondaryBrush" Color="#B7B3A8" />
|
||||||
|
<SolidColorBrush x:Key="KronaTextInverseBrush" Color="#171A18" />
|
||||||
|
<SolidColorBrush x:Key="KronaBorderBrush" Color="#39423A" />
|
||||||
|
<SolidColorBrush x:Key="KronaPrimaryBrush" Color="#527D60" />
|
||||||
|
<SolidColorBrush x:Key="KronaPrimarySoftBrush" Color="#29382E" />
|
||||||
|
<SolidColorBrush x:Key="KronaGoldBrush" Color="#C99A4D" />
|
||||||
|
<SolidColorBrush x:Key="KronaGoldSoftBrush" Color="#403522" />
|
||||||
|
</ResourceDictionary>
|
||||||
|
</ResourceDictionary.ThemeDictionaries>
|
||||||
|
<CornerRadius x:Key="KronaRadiusLarge">16</CornerRadius>
|
||||||
|
</ResourceDictionary>
|
||||||