diff --git a/apps/KronaStaffTerminal/App.xaml b/apps/KronaStaffTerminal/App.xaml
index 0410b9e..79d83c7 100644
--- a/apps/KronaStaffTerminal/App.xaml
+++ b/apps/KronaStaffTerminal/App.xaml
@@ -8,9 +8,8 @@
-
+
-
diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs
index bb0de45..82d6e65 100644
--- a/apps/KronaStaffTerminal/App.xaml.cs
+++ b/apps/KronaStaffTerminal/App.xaml.cs
@@ -44,6 +44,7 @@ public partial class App : Application
public App()
{
InitializeComponent();
+ UnhandledException += (_, eventArgs) => CrashLogService.Write(eventArgs.Exception);
}
///
diff --git a/apps/KronaStaffTerminal/Assets/AppIcon.ico b/apps/KronaStaffTerminal/Assets/AppIcon.ico
index 235dad2..ec5a6e9 100644
Binary files a/apps/KronaStaffTerminal/Assets/AppIcon.ico and b/apps/KronaStaffTerminal/Assets/AppIcon.ico differ
diff --git a/apps/KronaStaffTerminal/Assets/KronaAppIcon.png b/apps/KronaStaffTerminal/Assets/KronaAppIcon.png
new file mode 100644
index 0000000..d44bdd2
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/KronaAppIcon.png differ
diff --git a/apps/KronaStaffTerminal/Assets/KronaAppMark.png b/apps/KronaStaffTerminal/Assets/KronaAppMark.png
new file mode 100644
index 0000000..3ec8716
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/KronaAppMark.png differ
diff --git a/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png b/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png
index 33f889e..be98e21 100644
Binary files a/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png and b/apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png b/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png
index 802c79d..0217b0e 100644
Binary files a/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png and b/apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png b/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png
index ddba42a..cd0a881 100644
Binary files a/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png and b/apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png b/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png
index 9327dd7..3da9175 100644
Binary files a/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png and b/apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png
index e51416a..950dcdf 100644
Binary files a/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png and b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-24_altform-unplated.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png
index bf063eb..be98e21 100644
Binary files a/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png and b/apps/KronaStaffTerminal/Assets/Square44x44Logo.targetsize-48_altform-lightunplated.png differ
diff --git a/apps/KronaStaffTerminal/Assets/StoreLogo.png b/apps/KronaStaffTerminal/Assets/StoreLogo.png
index be865f0..22f939a 100644
Binary files a/apps/KronaStaffTerminal/Assets/StoreLogo.png and b/apps/KronaStaffTerminal/Assets/StoreLogo.png differ
diff --git a/apps/KronaStaffTerminal/Assets/TrushenkoLogo.png b/apps/KronaStaffTerminal/Assets/TrushenkoLogo.png
new file mode 100644
index 0000000..24792d9
Binary files /dev/null and b/apps/KronaStaffTerminal/Assets/TrushenkoLogo.png differ
diff --git a/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png b/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png
index 314d297..82d0173 100644
Binary files a/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png and b/apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png differ
diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
index 4e87419..b668414 100644
--- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
+++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj
@@ -5,6 +5,7 @@
10.0.17763.0
KronaStaffTerminal
app.manifest
+ Assets\AppIcon.ico
x86;x64;ARM64
win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())
win-$(Platform).pubxml
@@ -27,6 +28,9 @@
+
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml b/apps/KronaStaffTerminal/MainPage.xaml
index 2d494fd..251919e 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml
+++ b/apps/KronaStaffTerminal/MainPage.xaml
@@ -10,7 +10,9 @@
Unloaded="Page_Unloaded"
mc:Ignorable="d">
-
+
@@ -23,25 +25,75 @@
Title="Подключение"
Message="Запуск терминала…"/>
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
@@ -63,19 +115,30 @@
-
+
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -86,25 +149,32 @@
+
+
+
+
+
-
+
-
+
-
-
-
+
+
+
+
-
-
+
+
+
@@ -113,10 +183,10 @@
-
+
-
+
@@ -131,8 +201,12 @@
-
+
+
diff --git a/apps/KronaStaffTerminal/MainPage.xaml.cs b/apps/KronaStaffTerminal/MainPage.xaml.cs
index 555a3a8..1e24099 100644
--- a/apps/KronaStaffTerminal/MainPage.xaml.cs
+++ b/apps/KronaStaffTerminal/MainPage.xaml.cs
@@ -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? _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 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 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 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 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(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 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);
diff --git a/apps/KronaStaffTerminal/MainWindow.xaml.cs b/apps/KronaStaffTerminal/MainWindow.xaml.cs
index 1704193..739d116 100644
--- a/apps/KronaStaffTerminal/MainWindow.xaml.cs
+++ b/apps/KronaStaffTerminal/MainWindow.xaml.cs
@@ -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();
diff --git a/apps/KronaStaffTerminal/Models/TerminalModels.cs b/apps/KronaStaffTerminal/Models/TerminalModels.cs
index 9acacc9..a38886c 100644
--- a/apps/KronaStaffTerminal/Models/TerminalModels.cs
+++ b/apps/KronaStaffTerminal/Models/TerminalModels.cs
@@ -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 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
diff --git a/apps/KronaStaffTerminal/README.md b/apps/KronaStaffTerminal/README.md
index 9b927d9..fc1f232 100644
--- a/apps/KronaStaffTerminal/README.md
+++ b/apps/KronaStaffTerminal/README.md
@@ -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, затем отпечаток администратора. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
+После первичной настройки добавление сотрудников, регистрация и удаление отпечатков, изменение прав, уведомлений и параметров сканера доступны только в административном сеансе. Крестик скрывает окно в системный трей, сканер продолжает работать. Двойной щелчок по значку возвращает окно. Автозагрузка включена по умолчанию.
## Уведомления
diff --git a/apps/KronaStaffTerminal/Services/CrashLogService.cs b/apps/KronaStaffTerminal/Services/CrashLogService.cs
new file mode 100644
index 0000000..6418176
--- /dev/null
+++ b/apps/KronaStaffTerminal/Services/CrashLogService.cs
@@ -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.
+ }
+ }
+}
diff --git a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
index 6e3e387..56e5c80 100644
--- a/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
+++ b/apps/KronaStaffTerminal/Services/FingerprintScanner.cs
@@ -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();
diff --git a/apps/KronaStaffTerminal/Services/TerminalStore.cs b/apps/KronaStaffTerminal/Services/TerminalStore.cs
index d288b5a..00b41f4 100644
--- a/apps/KronaStaffTerminal/Services/TerminalStore.cs
+++ b/apps/KronaStaffTerminal/Services/TerminalStore.cs
@@ -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(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)!;
diff --git a/apps/KronaStaffTerminal/Styles/KronaTheme.xaml b/apps/KronaStaffTerminal/Styles/KronaTheme.xaml
new file mode 100644
index 0000000..0dba2c1
--- /dev/null
+++ b/apps/KronaStaffTerminal/Styles/KronaTheme.xaml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 16
+