using KronaStaffTerminal.Models; using KronaStaffTerminal.Services; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace KronaStaffTerminal; public sealed partial class MainPage : Page { private readonly TerminalStore _store = new(); private readonly NotificationService _notifications = new(); private readonly FingerprintScanner _scanner = new(); private TerminalState _state = new(); private bool _loaded; private TaskCompletionSource? _adminAuthCompletion; private ContentDialog? _adminAuthDialog; public MainPage() { InitializeComponent(); _scanner.StatusChanged += message => Enqueue(() => SetStatus(message)); _scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3")); _scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template)); _scanner.FingerRecognized += (id, score) => Enqueue(() => _ = HandleRecognizedFingerAsync(id, score)); } private async void Page_Loaded(object sender, RoutedEventArgs e) { if (_loaded) return; _loaded = true; _state = await _store.LoadAsync(); LoadSettings(); ApplyStartupSetting(); RefreshLists(); ConnectScanner(); } private void Page_Unloaded(object sender, RoutedEventArgs e) => _scanner.Dispose(); private void ConnectScanner() { try { _scanner.Connect(_state.Settings.ReaderIndex, _state.Employees .Where(employee => employee.HasFingerprint) .Select(employee => (employee.FingerprintId, employee.FingerprintTemplate!))); 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); } catch (Exception exception) { SetStatus(exception.Message, InfoBarSeverity.Error); } } private async void AddEmployee_Click(object sender, RoutedEventArgs e) { var name = EmployeeNameBox.Text.Trim(); var number = PersonnelNumberBox.Text.Trim(); if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number)) { SetStatus("Заполните имя и табельный номер сотрудника.", InfoBarSeverity.Warning); return; } if (_state.Employees.Any(employee => employee.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase))) { SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning); return; } if (AdministratorCheckBox.IsChecked == true && SecurityConfigured && !await AuthorizeAdminAsync("назначение нового администратора")) { return; } var employee = new EmployeeRecord { DisplayName = name, PersonnelNumber = number, Position = PositionBox.Text.Trim(), IsAdministrator = AdministratorCheckBox.IsChecked == true }; _state.Employees.Add(employee); await _store.SaveAsync(_state); EmployeeNameBox.Text = ""; PersonnelNumberBox.Text = ""; PositionBox.Text = ""; AdministratorCheckBox.IsChecked = false; RefreshLists(); EmployeesList.SelectedItem = employee; SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success); } private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e) { if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (!_scanner.IsConnected) { 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); } private async Task CompleteRegistrationAsync(int fingerprintId, string template) { var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId); if (employee is null) return; employee.FingerprintTemplate = template; await _store.SaveAsync(_state); RefreshLists(); TerminalMessage.Text = $"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован"; SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success); } private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e) { if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return; if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1) { SetStatus("Нельзя снять права с единственного администратора с зарегистрированным отпечатком.", InfoBarSeverity.Warning); return; } employee.IsAdministrator = !employee.IsAdministrator; await _store.SaveAsync(_state); RefreshLists(); SetStatus(employee.IsAdministrator ? $"{employee.DisplayName} назначен администратором." : $"Права администратора у {employee.DisplayName} сняты.", InfoBarSeverity.Success); } private async void DeleteEmployee_Click(object sender, RoutedEventArgs e) { if (EmployeesList.SelectedItem is not EmployeeRecord employee) { SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning); return; } var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Удалить сотрудника и отпечаток?", Content = $"{employee.DisplayName}, его отпечаток и право доступа будут удалены из терминала.", PrimaryButtonText = "Продолжить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; if (!SecurityConfigured) { SetStatus("Сначала назначьте администратора с отпечатком и установите PIN.", InfoBarSeverity.Warning); return; } if (!await AuthorizeAdminAsync("удаление сотрудника и отпечатка")) return; if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1) { SetStatus("Нельзя удалить единственного администратора.", InfoBarSeverity.Warning); return; } _state.Employees.Remove(employee); await _store.SaveAsync(_state); RefreshLists(); ConnectScanner(); SetStatus("Сотрудник и его отпечаток удалены.", InfoBarSeverity.Success); } private async Task HandleRecognizedFingerAsync(int fingerprintId, int score) { var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId); if (employee is null) return; if (_adminAuthCompletion is not null) { if (employee.IsAdministrator) { TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён"; _adminAuthCompletion.TrySetResult(true); _adminAuthDialog?.Hide(); } else { SetStatus($"{employee.DisplayName} не имеет прав администратора.", InfoBarSeverity.Warning); } return; } await RecordAttendanceAsync(employee, score); } private async Task RecordAttendanceAsync(EmployeeRecord employee, int score) { var now = DateTimeOffset.UtcNow; var last = _state.Attendance .Where(item => item.EmployeeId == employee.Id) .OrderByDescending(item => item.OccurredAtUtc) .FirstOrDefault(); var guard = TimeSpan.FromSeconds(Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600)); if (last is not null && now - last.OccurredAtUtc < guard) { var remaining = Math.Max(1, (int)Math.Ceiling((guard - (now - last.OccurredAtUtc)).TotalSeconds)); TerminalMessage.Text = $"{employee.DisplayName}\nПовторный скан отклонён\nПовторите через {remaining} сек."; SetStatus($"Повторный скан защищён: событие не создано. Последнее событие — «{last.EventType}».", InfoBarSeverity.Informational); return; } var isNewDay = last is null || last.OccurredAtUtc.ToLocalTime().Date != now.ToLocalTime().Date; var eventType = isNewDay || last!.EventType == "Уход" ? "Приход" : "Уход"; var record = new AttendanceRecord { EmployeeId = employee.Id, EmployeeName = employee.DisplayName, OccurredAtUtc = now, ReaderIndex = _scanner.ReaderIndex, EventType = eventType }; _state.Attendance.Insert(0, record); await _store.SaveAsync(_state); RefreshLists(); TerminalMessage.Text = $"{employee.DisplayName}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}"; SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success); var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee.DisplayName, eventType, now.ToLocalTime()); record.NotificationDelivered = delivery.Delivered; record.NotificationError = delivery.Error; await _store.SaveAsync(_state); RefreshLists(); if (delivery.Error is not null) { SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning); } } public async Task AuthorizeExitAsync() { if (!SecurityConfigured) { SetStatus("Выход заблокирован: сначала настройте PIN и отпечаток администратора.", InfoBarSeverity.Warning); return false; } return await AuthorizeAdminAsync("полное завершение терминала"); } private bool SecurityConfigured => !string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && _state.Employees.Any(employee => employee.IsAdministrator && employee.HasFingerprint); private async Task AuthorizeAdminAsync(string operation) { var pinBox = new PasswordBox { Header = "PIN администратора", PasswordRevealMode = PasswordRevealMode.Peek, MinWidth = 280 }; var pinDialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Подтверждение: {operation}", Content = pinBox, PrimaryButtonText = "Продолжить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await pinDialog.ShowAsync() != ContentDialogResult.Primary) return false; if (!AdminPinService.Verify(pinBox.Password, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash)) { SetStatus("Неверный PIN администратора.", InfoBarSeverity.Error); return false; } _adminAuthCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _adminAuthDialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Приложите палец администратора", Content = "PIN принят. Теперь приложите зарегистрированный палец сотрудника с правами администратора.", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; _ = ObserveAdminDialogAsync(_adminAuthDialog, _adminAuthCompletion); var authorized = await _adminAuthCompletion.Task; _adminAuthCompletion = null; _adminAuthDialog = null; SetStatus(authorized ? "Действие подтверждено администратором." : "Подтверждение отменено.", authorized ? InfoBarSeverity.Success : 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) { var newPin = NewAdminPinBox.Password.Trim(); if (newPin.Length < 4) { SetStatus("PIN должен содержать не менее 4 символов.", InfoBarSeverity.Warning); return; } if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return; var pin = AdminPinService.Hash(newPin); _state.Settings.AdminPinSalt = pin.Salt; _state.Settings.AdminPinHash = pin.Hash; NewAdminPinBox.Password = ""; await _store.SaveAsync(_state); SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success); } private async void SaveSettings_Click(object sender, RoutedEventArgs e) { CopySettingsFromControls(); await _store.SaveAsync(_state); ApplyStartupSetting(); ConnectScanner(); SetStatus("Настройки сохранены.", InfoBarSeverity.Success); } private async void TestNotification_Click(object sender, RoutedEventArgs e) { CopySettingsFromControls(); var result = await _notifications.SendTestAsync(_state.Settings); SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning); } private void CopySettingsFromControls() { _state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex); _state.Settings.StartWithWindows = StartWithWindowsSwitch.IsOn; _state.Settings.DuplicateScanGuardSeconds = double.IsNaN(DuplicateGuardBox.Value) ? 120 : Math.Clamp((int)DuplicateGuardBox.Value, 10, 3600); _state.Settings.TelegramEnabled = TelegramEnabledSwitch.IsOn; _state.Settings.TelegramBotToken = TelegramTokenBox.Password.Trim(); _state.Settings.TelegramChatId = TelegramChatBox.Text.Trim(); _state.Settings.MaxEnabled = MaxEnabledSwitch.IsOn; _state.Settings.MaxBotToken = MaxTokenBox.Password.Trim(); _state.Settings.MaxChatId = MaxChatBox.Text.Trim(); } private void LoadSettings() { StartWithWindowsSwitch.IsOn = _state.Settings.StartWithWindows; DuplicateGuardBox.Value = Math.Clamp(_state.Settings.DuplicateScanGuardSeconds, 10, 3600); TelegramEnabledSwitch.IsOn = _state.Settings.TelegramEnabled; TelegramTokenBox.Password = _state.Settings.TelegramBotToken; TelegramChatBox.Text = _state.Settings.TelegramChatId; MaxEnabledSwitch.IsOn = _state.Settings.MaxEnabled; MaxTokenBox.Password = _state.Settings.MaxBotToken; MaxChatBox.Text = _state.Settings.MaxChatId; } private void ApplyStartupSetting() { try { StartupService.SetEnabled(_state.Settings.StartWithWindows); StartWithWindowsSwitch.IsOn = StartupService.IsEnabled(); } catch (Exception exception) { SetStatus($"Не удалось изменить автозагрузку: {exception.Message}", InfoBarSeverity.Warning); } } private void RefreshLists() { EmployeesList.ItemsSource = null; EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList(); AttendanceList.ItemsSource = null; AttendanceList.ItemsSource = _state.Attendance.OrderByDescending(item => item.OccurredAtUtc).Take(100).ToList(); } private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational) { StatusBar.Message = message; StatusBar.Severity = severity; StatusBar.IsOpen = true; } private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action()); }