|
|
@ -8,9 +8,8 @@
|
|||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
<!-- Other merged dictionaries here -->
|
||||
<ResourceDictionary Source="ms-appx:///Styles/KronaTheme.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Other app resources here -->
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ public partial class App : Application
|
|||
public App()
|
||||
{
|
||||
InitializeComponent();
|
||||
UnhandledException += (_, eventArgs) => CrashLogService.Write(eventArgs.Exception);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
<RootNamespace>KronaStaffTerminal</RootNamespace>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\AppIcon.ico</ApplicationIcon>
|
||||
<Platforms>x86;x64;ARM64</Platforms>
|
||||
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
|
||||
<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\StoreLogo.png" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@
|
|||
Unloaded="Page_Unloaded"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<Grid Padding="24,16,24,24" RowSpacing="12">
|
||||
<Grid Padding="24,16,24,24"
|
||||
RowSpacing="12"
|
||||
Background="{ThemeResource KronaAppBackgroundBrush}">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
|
|
@ -23,25 +25,75 @@
|
|||
Title="Подключение"
|
||||
Message="Запуск терминала…"/>
|
||||
|
||||
<TabView Grid.Row="1" IsAddTabButtonVisible="False">
|
||||
<TabViewItem Header="Терминал" IsClosable="False">
|
||||
<TabView x:Name="MainTabs"
|
||||
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.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2*"/>
|
||||
<ColumnDefinition Width="3*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="16">
|
||||
<FontIcon Glyph="" FontSize="88"/>
|
||||
<TextBlock Text="Приложите палец" Style="{StaticResource TitleTextBlockStyle}" HorizontalAlignment="Center"/>
|
||||
<TextBlock x:Name="TerminalMessage" Text="Ожидание сканирования" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="360"/>
|
||||
</StackPanel>
|
||||
<Border Margin="8"
|
||||
Padding="28"
|
||||
Background="{ThemeResource KronaSurfaceBrush}"
|
||||
BorderBrush="{ThemeResource KronaBorderBrush}"
|
||||
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.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Последние отметки" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||
<ListView x:Name="AttendanceList" Grid.Row="1" SelectionMode="None">
|
||||
<Grid ColumnSpacing="12">
|
||||
<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>
|
||||
<DataTemplate x:DataType="models:AttendanceRecord">
|
||||
<Grid Padding="4,10" ColumnSpacing="16">
|
||||
|
|
@ -63,19 +115,30 @@
|
|||
</Grid>
|
||||
</TabViewItem>
|
||||
|
||||
<TabViewItem Header="Сотрудники" IsClosable="False">
|
||||
<TabViewItem x:Name="EmployeesTab" Header="Сотрудники" IsClosable="False">
|
||||
<Grid Padding="4,16,4,4" RowSpacing="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Text="Добавление и регистрация сотрудников" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||
<CommandBar Grid.Row="1" DefaultLabelPosition="Right">
|
||||
<TextBlock Text="Добавление и регистрация сотрудников" Foreground="{ThemeResource KronaTextPrimaryBrush}" Style="{StaticResource SubtitleTextBlockStyle}"/>
|
||||
<CommandBar Grid.Row="1" Background="{ThemeResource KronaSurfaceBrush}" DefaultLabelPosition="Right">
|
||||
<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="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
|
||||
<AppBarSeparator />
|
||||
<AppBarButton Label="Заблокировать" Click="LogoutAdmin_Click">
|
||||
<AppBarButton.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</AppBarButton.Icon>
|
||||
</AppBarButton>
|
||||
</CommandBar>
|
||||
<Grid Grid.Row="2" ColumnSpacing="20">
|
||||
<Grid.ColumnDefinitions>
|
||||
|
|
@ -86,25 +149,32 @@
|
|||
<TextBox x:Name="PersonnelNumberBox" Header="Табельный номер" PlaceholderText="Например, 001" AutomationProperties.Name="Табельный номер"/>
|
||||
<TextBox x:Name="EmployeeNameBox" 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="Администратор терминала"/>
|
||||
<TextBlock Text="Выберите сотрудника справа и нажмите «Зарегистрировать отпечаток». Сканер попросит приложить один палец три раза." TextWrapping="Wrap" Opacity="0.75"/>
|
||||
<TextBlock Text="Выберите сотрудника справа и нажмите «Добавить отпечаток». Для каждого нового пальца повторите регистрацию из трёх сканов." TextWrapping="Wrap" Opacity="0.75"/>
|
||||
</StackPanel>
|
||||
<ListView x:Name="EmployeesList" Grid.Column="1" SelectionMode="Single">
|
||||
<ListView x:Name="EmployeesList" Grid.Column="1" Background="{ThemeResource KronaRaisedSurfaceBrush}" SelectionMode="Single">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:EmployeeRecord">
|
||||
<Grid Padding="4,10" ColumnSpacing="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="90"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="150"/>
|
||||
<ColumnDefinition Width="140"/>
|
||||
<ColumnDefinition Width="210"/>
|
||||
<ColumnDefinition Width="130"/>
|
||||
<ColumnDefinition Width="90"/>
|
||||
<ColumnDefinition Width="130"/>
|
||||
<ColumnDefinition Width="170"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="{x:Bind PersonnelNumber}"/>
|
||||
<TextBlock Grid.Column="1" Text="{x:Bind DisplayName}" FontWeight="SemiBold"/>
|
||||
<TextBlock Grid.Column="2" Text="{x:Bind Position}"/>
|
||||
<TextBlock Grid.Column="3" Text="{x:Bind AccessStatus}"/>
|
||||
<TextBlock Grid.Column="4" Text="{x:Bind FingerprintStatus}"/>
|
||||
<TextBlock Grid.Column="3" Text="{x:Bind GenderDisplay}"/>
|
||||
<TextBlock Grid.Column="4" Text="{x:Bind AccessStatus}"/>
|
||||
<TextBlock Grid.Column="5" Text="{x:Bind FingerprintStatus}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
|
|
@ -113,10 +183,10 @@
|
|||
</Grid>
|
||||
</TabViewItem>
|
||||
|
||||
<TabViewItem Header="Уведомления и сканер" IsClosable="False">
|
||||
<TabViewItem x:Name="SettingsTab" Header="Уведомления и сканер" IsClosable="False">
|
||||
<ScrollViewer>
|
||||
<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"/>
|
||||
<ToggleSwitch x:Name="StartWithWindowsSwitch" Header="Автозагрузка" OffContent="Выключена" OnContent="Запускать вместе с Windows"/>
|
||||
<NumberBox x:Name="DuplicateGuardBox" Header="Защита от повторного скана, секунд" Minimum="10" Maximum="3600" SpinButtonPlacementMode="Compact"/>
|
||||
|
|
@ -131,8 +201,12 @@
|
|||
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
|
||||
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
|
||||
<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="LogoutAdmin_Click"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
|
||||
</StackPanel>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using KronaStaffTerminal.Models;
|
||||
using KronaStaffTerminal.Services;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
|
|
@ -14,6 +15,12 @@ public sealed partial class MainPage : Page
|
|||
private bool _loaded;
|
||||
private TaskCompletionSource<bool>? _adminAuthCompletion;
|
||||
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()
|
||||
{
|
||||
|
|
@ -22,6 +29,17 @@ public sealed partial class MainPage : Page
|
|||
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
|
||||
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
|
||||
_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)
|
||||
|
|
@ -33,9 +51,118 @@ public sealed partial class MainPage : Page
|
|||
ApplyStartupSetting();
|
||||
RefreshLists();
|
||||
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()
|
||||
{
|
||||
|
|
@ -43,14 +170,29 @@ public sealed partial class MainPage : Page
|
|||
{
|
||||
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
|
||||
.Where(employee => employee.HasFingerprint)
|
||||
.Select(employee => (employee.FingerprintId, employee.FingerprintTemplate!)));
|
||||
.SelectMany(employee => employee.Fingerprints)
|
||||
.Select(fingerprint => (fingerprint.Id, fingerprint.Template)));
|
||||
ReaderCombo.Items.Clear();
|
||||
for (var index = 0; index < _scanner.DeviceCount; index++)
|
||||
{
|
||||
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
|
||||
}
|
||||
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)
|
||||
{
|
||||
|
|
@ -60,6 +202,7 @@ public sealed partial class MainPage : Page
|
|||
|
||||
private async void AddEmployee_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("добавление сотрудника")) return;
|
||||
var name = EmployeeNameBox.Text.Trim();
|
||||
var number = PersonnelNumberBox.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
|
||||
|
|
@ -83,6 +226,12 @@ public sealed partial class MainPage : Page
|
|||
DisplayName = name,
|
||||
PersonnelNumber = number,
|
||||
Position = PositionBox.Text.Trim(),
|
||||
Gender = GenderComboBox.SelectedIndex switch
|
||||
{
|
||||
1 => "Мужской",
|
||||
2 => "Женский",
|
||||
_ => ""
|
||||
},
|
||||
IsAdministrator = AdministratorCheckBox.IsChecked == true
|
||||
};
|
||||
_state.Employees.Add(employee);
|
||||
|
|
@ -90,6 +239,7 @@ public sealed partial class MainPage : Page
|
|||
EmployeeNameBox.Text = "";
|
||||
PersonnelNumberBox.Text = "";
|
||||
PositionBox.Text = "";
|
||||
GenderComboBox.SelectedIndex = 0;
|
||||
AdministratorCheckBox.IsChecked = false;
|
||||
RefreshLists();
|
||||
EmployeesList.SelectedItem = employee;
|
||||
|
|
@ -98,6 +248,7 @@ public sealed partial class MainPage : Page
|
|||
|
||||
private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
|
||||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||
{
|
||||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||
|
|
@ -108,31 +259,68 @@ public sealed partial class MainPage : Page
|
|||
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
|
||||
return;
|
||||
}
|
||||
if (employee.HasFingerprint && SecurityConfigured &&
|
||||
!await AuthorizeAdminAsync("замена зарегистрированного отпечатка"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (employee.FingerprintId <= 0)
|
||||
{
|
||||
employee.FingerprintId = _state.Employees.Select(item => item.FingerprintId).DefaultIfEmpty(0).Max() + 1;
|
||||
}
|
||||
_scanner.BeginRegistration(employee.FingerprintId);
|
||||
var fingerprintId = _state.Employees
|
||||
.SelectMany(item => item.Fingerprints)
|
||||
.Select(item => item.Id)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max() + 1;
|
||||
_registrationEmployeeId = employee.Id;
|
||||
_scanner.BeginRegistration(fingerprintId);
|
||||
}
|
||||
|
||||
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;
|
||||
employee.FingerprintTemplate = template;
|
||||
employee.Fingerprints.Add(new FingerprintRecord
|
||||
{
|
||||
Id = fingerprintId,
|
||||
Template = template,
|
||||
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
|
||||
});
|
||||
_registrationEmployeeId = null;
|
||||
await _store.SaveAsync(_state);
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
|
||||
SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
|
||||
TerminalMessage.Text = $"Новый отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
|
||||
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)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("изменение прав администратора")) return;
|
||||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||
{
|
||||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||
|
|
@ -152,6 +340,7 @@ public sealed partial class MainPage : Page
|
|||
|
||||
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("удаление сотрудника")) return;
|
||||
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
|
||||
{
|
||||
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
|
||||
|
|
@ -169,7 +358,7 @@ public sealed partial class MainPage : Page
|
|||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
if (!SecurityConfigured)
|
||||
{
|
||||
SetStatus("Сначала назначьте администратора с отпечатком и установите PIN.", InfoBarSeverity.Warning);
|
||||
SetStatus("Сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||||
return;
|
||||
}
|
||||
if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return;
|
||||
|
|
@ -188,7 +377,8 @@ public sealed partial class MainPage : Page
|
|||
|
||||
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 (_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()
|
||||
{
|
||||
if (!SecurityConfigured)
|
||||
{
|
||||
SetStatus("Выход заблокирован: сначала настройте PIN и отпечаток администратора.", InfoBarSeverity.Warning);
|
||||
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
|
||||
return false;
|
||||
}
|
||||
return await AuthorizeAdminAsync("полное завершение терминала");
|
||||
}
|
||||
|
||||
private bool SecurityConfigured =>
|
||||
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) &&
|
||||
private bool PinConfigured =>
|
||||
!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash);
|
||||
|
||||
private bool AdministratorFingerprintConfigured =>
|
||||
_state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint);
|
||||
|
||||
private bool SecurityConfigured =>
|
||||
PinConfigured || AdministratorFingerprintConfigured;
|
||||
|
||||
private async Task<bool> AuthorizeAdminAsync(string operation)
|
||||
{
|
||||
var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280 };
|
||||
var pinDialog = new ContentDialog
|
||||
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
|
||||
var pinBox = new PasswordBox
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = $"Подтверждение: {operation}",
|
||||
Content = pinBox,
|
||||
PrimaryButtonText = "Продолжить",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
Header = "PIN администратора",
|
||||
PasswordRevealMode = PasswordRevealMode.Peek,
|
||||
MinWidth = 280
|
||||
};
|
||||
if (await pinDialog.ShowAsync() != ContentDialogResult.Primary) return false;
|
||||
if (!AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash))
|
||||
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
SetStatus("Неверный PIN администратора.", InfoBarSeverity.Error);
|
||||
return false;
|
||||
}
|
||||
Text = hasAdministratorFingerprint
|
||||
? "Введите PIN или приложите зарегистрированный палец администратора."
|
||||
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
content.Children.Add(pinBox);
|
||||
|
||||
_adminAuthCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_adminAuthDialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Приложите палец администратора",
|
||||
Content = "PIN принят. Теперь приложите зарегистрированный палец сотрудника с правами администратора.",
|
||||
Title = $"Вход администратора: {operation}",
|
||||
Content = content,
|
||||
PrimaryButtonText = "Войти по PIN",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Close
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
_ = ObserveAdminDialogAsync(_adminAuthDialog, _adminAuthCompletion);
|
||||
var authorized = await _adminAuthCompletion.Task;
|
||||
var dialogResult = await _adminAuthDialog.ShowAsync();
|
||||
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;
|
||||
_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;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("изменение PIN")) return;
|
||||
var newPin = NewAdminPinBox.Password.Trim();
|
||||
if (newPin.Length < 4)
|
||||
if (newPin.Length < 6)
|
||||
{
|
||||
SetStatus("PIN должен содержать не менее 4 символов.", InfoBarSeverity.Warning);
|
||||
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
|
||||
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)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
|
||||
CopySettingsFromControls();
|
||||
await _store.SaveAsync(_state);
|
||||
ApplyStartupSetting();
|
||||
|
|
@ -334,6 +556,7 @@ public sealed partial class MainPage : Page
|
|||
|
||||
private async void TestNotification_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!await EnsureAdminSessionAsync("проверка уведомлений")) return;
|
||||
CopySettingsFromControls();
|
||||
var result = await _notifications.SendTestAsync(_state.Settings);
|
||||
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.Resize(new Windows.Graphics.SizeInt32(1120, 760));
|
||||
AppWindow.Closing += AppWindow_Closing;
|
||||
AppWindow.Changed += AppWindow_Changed;
|
||||
|
||||
// Navigate the root frame to the main page on startup.
|
||||
RootFrame.Navigate(typeof(MainPage));
|
||||
|
|
@ -33,10 +34,23 @@ public sealed partial class MainWindow : Window
|
|||
{
|
||||
if (_allowClose) return;
|
||||
args.Cancel = true;
|
||||
if (RootFrame.Content is MainPage page) page.LockAdminSession();
|
||||
AppWindow.Hide();
|
||||
((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()
|
||||
{
|
||||
AppWindow.Show();
|
||||
|
|
|
|||
|
|
@ -8,18 +8,38 @@ public sealed class EmployeeRecord
|
|||
public string PersonnelNumber { get; set; } = "";
|
||||
public string DisplayName { get; set; } = "";
|
||||
public string Position { get; set; } = "";
|
||||
public string Gender { 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 string? FingerprintTemplate { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public bool HasFingerprint => FingerprintId > 0 && !string.IsNullOrWhiteSpace(FingerprintTemplate);
|
||||
public bool HasFingerprint => Fingerprints.Count > 0;
|
||||
|
||||
[JsonIgnore]
|
||||
public string FingerprintStatus => HasFingerprint ? "Отпечаток зарегистрирован" : "Отпечатка нет";
|
||||
public string FingerprintStatus => Fingerprints.Count switch
|
||||
{
|
||||
0 => "Отпечатков нет",
|
||||
1 => "1 отпечаток",
|
||||
2 or 3 or 4 => $"{Fingerprints.Count} отпечатка",
|
||||
_ => $"{Fingerprints.Count} отпечатков"
|
||||
};
|
||||
|
||||
[JsonIgnore]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,15 +4,17 @@
|
|||
|
||||
## Возможности
|
||||
|
||||
- выбор одного из подключённых сканеров;
|
||||
- добавление сотрудников с должностью и правами администратора;
|
||||
- регистрация отпечатка по трём последовательным сканам;
|
||||
- выбор одного из подключённых сканеров с автоматическим переходом на рабочее устройство;
|
||||
- добавление сотрудников с должностью, полом и правами администратора;
|
||||
- несколько отпечатков у одного сотрудника, каждый регистрируется по трём последовательным сканам;
|
||||
- чередование событий «Приход» и «Уход»;
|
||||
- настраиваемая защита от повторного сканирования, по умолчанию 120 секунд;
|
||||
- локальный журнал последних отметок;
|
||||
- защищённая очистка журнала без удаления сотрудников и отпечатков;
|
||||
- уведомления через Telegram Bot API и MAX Bot API;
|
||||
- автозагрузка вместе с Windows и постоянная работа в системном трее;
|
||||
- удаление сотрудника и завершение терминала только по PIN и отпечатку администратора;
|
||||
- закрытый административный раздел с входом по PIN или отпечатку администратора;
|
||||
- автоматическая блокировка административного раздела через 5 минут и при сворачивании;
|
||||
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
|
||||
|
||||
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
|
||||
|
|
@ -27,20 +29,21 @@ dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug
|
|||
|
||||
## Первый сотрудник
|
||||
|
||||
1. Откройте вкладку «Сотрудники».
|
||||
2. Введите табельный номер и имя, затем нажмите «Добавить».
|
||||
3. Выберите сотрудника в списке и нажмите «Зарегистрировать отпечаток».
|
||||
1. Откройте вкладку «Сотрудники» и при первом входе создайте PIN длиной не менее шести символов.
|
||||
2. Введите табельный номер и имя, отметьте «Администратор терминала», затем нажмите «Добавить».
|
||||
3. Выберите сотрудника в списке и нажмите «Добавить отпечаток».
|
||||
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
|
||||
5. Для регистрации другого пальца повторно нажмите «Добавить отпечаток».
|
||||
|
||||
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Первое сканирование создаёт «Приход», следующее после защитного интервала — «Уход». Удержание пальца и повторное прикладывание в течение защитного интервала не создают новое событие.
|
||||
|
||||
## Администратор и работа в фоне
|
||||
|
||||
1. Выберите сотрудника и нажмите «Права администратора».
|
||||
2. Убедитесь, что у него зарегистрирован отпечаток.
|
||||
3. На вкладке настроек задайте PIN длиной не менее четырёх символов.
|
||||
1. Вкладки «Сотрудники» и «Уведомления и сканер» открываются только после входа администратора.
|
||||
2. Для входа достаточно 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 ReaderIndex { get; private set; }
|
||||
public int SkippedTemplateCount { get; private set; }
|
||||
public bool IsConnected => _device != IntPtr.Zero;
|
||||
|
||||
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
|
||||
{
|
||||
Disconnect();
|
||||
SkippedTemplateCount = 0;
|
||||
var result = zkfp2.Init();
|
||||
if (result != 0)
|
||||
{
|
||||
|
|
@ -40,12 +42,21 @@ public sealed class FingerprintScanner : IDisposable
|
|||
throw new InvalidOperationException("Подключённые сканеры не найдены.");
|
||||
}
|
||||
|
||||
ReaderIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
|
||||
_device = zkfp2.OpenDevice(ReaderIndex);
|
||||
var preferredIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
|
||||
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)
|
||||
{
|
||||
zkfp2.Terminate();
|
||||
throw new InvalidOperationException($"Не удалось открыть сканер №{ReaderIndex + 1}.");
|
||||
throw new InvalidOperationException("Ни один обнаруженный сканер не удалось открыть. Переподключите USB-кабель.");
|
||||
}
|
||||
|
||||
_database = zkfp2.DBInit();
|
||||
|
|
@ -59,8 +70,18 @@ public sealed class FingerprintScanner : IDisposable
|
|||
|
||||
foreach (var item in templates)
|
||||
{
|
||||
var template = zkfp2.Base64ToBlob(item.Template);
|
||||
zkfp2.DBAdd(_database, item.FingerprintId, template);
|
||||
try
|
||||
{
|
||||
var template = Convert.FromBase64String(item.Template);
|
||||
if (template.Length == 0 || zkfp2.DBAdd(_database, item.FingerprintId, template) != 0)
|
||||
{
|
||||
SkippedTemplateCount++;
|
||||
}
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
SkippedTemplateCount++;
|
||||
}
|
||||
}
|
||||
|
||||
_captureCancellation = new CancellationTokenSource();
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ public sealed class TerminalStore
|
|||
var encrypted = await File.ReadAllBytesAsync(sourcePath);
|
||||
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||
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);
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
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>
|
||||