krona-staff/apps/KronaStaffTerminal/MainPage.xaml.cs
Ochenstarik 7c8e7fcaf1
Some checks are pending
ci / verify (push) Waiting to run
fix: close terminal audit findings
2026-09-18 01:34:36 +07:00

904 lines
40 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using KronaStaffTerminal.Models;
using KronaStaffTerminal.Services;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System.Text;
using Windows.Storage;
using Windows.Storage.Pickers;
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<bool>? _adminAuthCompletion;
private ContentDialog? _adminAuthDialog;
private readonly DispatcherQueueTimer _adminSessionTimer;
private bool _adminSessionActive;
private bool _adminNavigationInProgress;
private bool _stateLoadFailed;
private bool _scannerReconnectPending;
private int _failedPinAttempts;
private DateTimeOffset _pinBlockedUntilUtc;
private string? _registrationEmployeeId;
private DateTimeOffset _adminSessionExpiresAtUtc;
private static readonly TimeSpan AdminSessionDuration = TimeSpan.FromMinutes(5);
public MainPage()
{
InitializeComponent();
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
_scanner.RegistrationCompleted += (id, template) => EnqueueTask(() => CompleteRegistrationAsync(id, template));
_scanner.FingerRecognized += (id, score) => EnqueueTask(() => HandleRecognizedFingerAsync(id, score));
_scanner.ConnectionLost += () => EnqueueTask(ReconnectScannerAsync);
_scanner.RegistrationCancelled += () => Enqueue(() => _registrationEmployeeId = null);
_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)
{
if (_loaded) return;
_loaded = true;
try
{
_state = await _store.LoadAsync();
}
catch (Exception exception)
{
_stateLoadFailed = true;
CrashLogService.Write(exception);
MainTabs.SelectedItem = TerminalTab;
TerminalMessage.Text = "Хранилище данных не открыто\nОбратитесь к администратору";
SetStatus(exception.Message, InfoBarSeverity.Error);
return;
}
LoadSettings();
ApplyStartupSetting();
RefreshLists();
ConnectScanner();
MainTabs.SelectedItem = TerminalTab;
}
private void Page_Unloaded(object sender, RoutedEventArgs e)
{
_loaded = false;
_adminSessionTimer.Stop();
_scanner.Dispose();
}
private async Task ReconnectScannerAsync()
{
if (_scannerReconnectPending || !_loaded) return;
_scannerReconnectPending = true;
try
{
_scanner.Disconnect();
while (_loaded && !_scanner.IsConnected)
{
await Task.Delay(TimeSpan.FromSeconds(3));
if (!_loaded) return;
ConnectScanner();
}
}
finally
{
_scannerReconnectPending = false;
}
}
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)
{
CancelRegistration();
_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 (_stateLoadFailed)
{
SetStatus("Административные действия заблокированы: хранилище данных не открыто.", InfoBarSeverity.Error);
return false;
}
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 = await Task.Run(() => AdminPinService.Hash(pinBox.Password));
_state.Settings.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations;
await _store.SaveAsync(_state);
return true;
}
private void ConnectScanner()
{
try
{
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
.Where(employee => employee.HasFingerprint)
.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;
if (_state.Settings.ReaderIndex != _scanner.ReaderIndex)
{
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
SaveStateInBackground();
}
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)
{
SetStatus(exception.Message, InfoBarSeverity.Error);
}
}
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))
{
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(),
Gender = GenderComboBox.SelectedIndex switch
{
1 => "Мужской",
2 => "Женский",
_ => ""
},
IsAdministrator = AdministratorCheckBox.IsChecked == true
};
_state.Employees.Add(employee);
await _store.SaveAsync(_state);
EmployeeNameBox.Text = "";
PersonnelNumberBox.Text = "";
PositionBox.Text = "";
GenderComboBox.SelectedIndex = 0;
AdministratorCheckBox.IsChecked = false;
RefreshLists();
EmployeesList.SelectedItem = employee;
SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success);
}
private async void EditEmployee_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("редактирование сотрудника")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
var personnelNumberBox = new TextBox
{
Header = "Табельный номер",
Text = employee.PersonnelNumber,
MinWidth = 360
};
var nameBox = new TextBox
{
Header = "Имя сотрудника",
Text = employee.DisplayName,
MinWidth = 360
};
var positionBox = new TextBox
{
Header = "Должность",
Text = employee.Position,
MinWidth = 360
};
var genderBox = new ComboBox
{
Header = "Пол",
MinWidth = 360,
ItemsSource = new[] { "Не указан", "Мужской", "Женский" },
SelectedIndex = employee.Gender switch
{
"Мужской" => 1,
"Женский" => 2,
_ => 0
}
};
var content = new StackPanel { Spacing = 12 };
content.Children.Add(personnelNumberBox);
content.Children.Add(nameBox);
content.Children.Add(positionBox);
content.Children.Add(genderBox);
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"Редактирование: {employee.DisplayName}",
Content = content,
PrimaryButtonText = "Сохранить",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
var number = personnelNumberBox.Text.Trim();
var name = nameBox.Text.Trim();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
{
SetStatus("Имя и табельный номер не могут быть пустыми.", InfoBarSeverity.Warning);
return;
}
if (_state.Employees.Any(item => !ReferenceEquals(item, employee) &&
item.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase)))
{
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
return;
}
employee.PersonnelNumber = number;
employee.DisplayName = name;
employee.Position = positionBox.Text.Trim();
employee.Gender = genderBox.SelectedIndex switch
{
1 => "Мужской",
2 => "Женский",
_ => ""
};
foreach (var attendance in _state.Attendance.Where(item => item.EmployeeId == employee.Id))
{
attendance.EmployeeName = name;
}
await _store.SaveAsync(_state);
RefreshLists();
EmployeesList.SelectedItem = employee;
SetStatus($"Данные сотрудника «{name}» обновлены. Отпечатки и журнал сохранены.", InfoBarSeverity.Success);
}
private async void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("регистрация отпечатка")) return;
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
if (!_scanner.IsConnected)
{
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
return;
}
var fingerprintId = _state.Employees
.SelectMany(item => item.Fingerprints)
.Select(item => item.Id)
.DefaultIfEmpty(0)
.Max() + 1;
_registrationEmployeeId = employee.Id;
_scanner.BeginRegistration(fingerprintId);
}
private void CancelRegistration_Click(object sender, RoutedEventArgs e)
{
if (_registrationEmployeeId is null)
{
SetStatus("Активной регистрации отпечатка нет.", InfoBarSeverity.Informational);
return;
}
CancelRegistration();
SetStatus("Регистрация отпечатка отменена.", InfoBarSeverity.Informational);
}
private void EmployeesList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_registrationEmployeeId is null) return;
if (EmployeesList.SelectedItem is EmployeeRecord employee && employee.Id == _registrationEmployeeId) return;
CancelRegistration();
SetStatus("Регистрация отпечатка отменена из-за смены сотрудника.", InfoBarSeverity.Warning);
}
private void CancelRegistration()
{
_scanner.CancelRegistration();
_registrationEmployeeId = null;
}
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
{
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
if (employee is null) return;
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.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;
}
if (employee.IsAdministrator && !PinConfigured &&
_state.Employees.Count(item => item.IsAdministrator && item.HasFingerprint) <= 1)
{
SetStatus("Нельзя удалить последний способ входа администратора.", InfoBarSeverity.Warning);
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);
return;
}
if (SecurityConfigured && !await AuthorizeAdminAsync("изменение прав администратора")) return;
if (employee.IsAdministrator && _state.Employees.Count(item => item.IsAdministrator) <= 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 (!await EnsureAdminSessionAsync("удаление сотрудника")) return;
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) <= 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.Fingerprints.Any(fingerprint => fingerprint.Id == fingerprintId));
if (employee is null) return;
if (_adminAuthCompletion is not null)
{
if (employee.IsAdministrator)
{
TerminalMessage.Text = $"Администратор {employee.DisplayName} подтверждён";
_adminAuthCompletion.TrySetResult(true);
_adminAuthDialog?.Hide();
}
else
{
await RecordAttendanceAsync(employee, score);
}
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 maximumShift = TimeSpan.FromHours(Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48));
var eventType = last is not null && last.EventType == "Приход" && now - last.OccurredAtUtc <= maximumShift
? "Уход"
: "Приход";
var record = new AttendanceRecord
{
EmployeeId = employee.Id,
EmployeeName = employee.DisplayName,
OccurredAtUtc = now,
ReaderIndex = _scanner.ReaderIndex,
EventType = eventType
};
_state.Attendance.Insert(0, record);
if (_state.Attendance.Count > 100_000)
{
_state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000);
}
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);
}
}
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);
}
private async void ExportAttendance_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("экспорт журнала")) return;
var picker = new FileSavePicker
{
SuggestedFileName = $"krona-staff-attendance-{DateTime.Now:yyyy-MM-dd}"
};
picker.FileTypeChoices.Add("CSV", new[] { ".csv" });
WinRT.Interop.InitializeWithWindow.Initialize(picker, App.WindowHandle);
var file = await picker.PickSaveFileAsync();
if (file is null) return;
var csv = new StringBuilder("Сотрудник;Табельный номер;Должность;Событие;Время;Доставка\r\n");
foreach (var item in _state.Attendance.OrderBy(record => record.OccurredAtUtc))
{
var employee = _state.Employees.FirstOrDefault(candidate => candidate.Id == item.EmployeeId);
csv.Append(Csv(item.EmployeeName)).Append(';')
.Append(Csv(employee?.PersonnelNumber ?? "")).Append(';')
.Append(Csv(employee?.Position ?? "")).Append(';')
.Append(Csv(item.EventType)).Append(';')
.Append(Csv(item.LocalTime)).Append(';')
.Append(Csv(item.DeliveryStatus)).Append("\r\n");
}
await FileIO.WriteTextAsync(file, csv.ToString(), Windows.Storage.Streams.UnicodeEncoding.Utf8);
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
}
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
public async Task<bool> AuthorizeExitAsync()
{
if (!SecurityConfigured)
{
SetStatus("Выход заблокирован: сначала настройте PIN или отпечаток администратора.", InfoBarSeverity.Warning);
return false;
}
return await AuthorizeAdminAsync("полное завершение терминала");
}
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 remainingLockout = _pinBlockedUntilUtc - DateTimeOffset.UtcNow;
if (remainingLockout > TimeSpan.Zero)
{
SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning);
return false;
}
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
var pinBox = new PasswordBox
{
Header = "PIN администратора",
PasswordRevealMode = PasswordRevealMode.Peek,
MinWidth = 280
};
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock
{
Text = hasAdministratorFingerprint
? "Введите PIN или приложите зарегистрированный палец администратора."
: "Введите PIN. После входа зарегистрируйте отпечаток администратора.",
TextWrapping = TextWrapping.Wrap
});
content.Children.Add(pinBox);
_adminAuthCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
_adminAuthDialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = $"Вход администратора: {operation}",
Content = content,
PrimaryButtonText = "Войти по PIN",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
var dialogResult = await _adminAuthDialog.ShowAsync();
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
_adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false);
var pinAuthorized = false;
if (dialogResult == ContentDialogResult.Primary)
{
pinAuthorized = await Task.Run(() =>
AdminPinService.Verify(
pinBox.Password,
_state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations) ||
(!string.Equals(pinBox.Password, pinBox.Password.Trim(), StringComparison.Ordinal) &&
AdminPinService.Verify(
pinBox.Password.Trim(),
_state.Settings.AdminPinSalt,
_state.Settings.AdminPinHash,
_state.Settings.AdminPinIterations)));
}
var authorized = fingerprintAuthorized || pinAuthorized;
if (authorized)
{
_failedPinAttempts = 0;
_pinBlockedUntilUtc = default;
}
else if (dialogResult == ContentDialogResult.Primary)
{
_failedPinAttempts++;
var delaySeconds = Math.Min(30, 1 << Math.Min(_failedPinAttempts - 1, 5));
_pinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
}
_adminAuthCompletion = null;
_adminAuthDialog = null;
SetStatus(authorized
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
: dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.",
authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning);
return authorized;
}
private async void SetAdminPin_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("изменение PIN")) return;
var newPin = NewAdminPinBox.Password.Trim();
if (newPin.Length < 6)
{
SetStatus("PIN должен содержать не менее 6 символов.", InfoBarSeverity.Warning);
return;
}
if (!string.IsNullOrWhiteSpace(_state.Settings.AdminPinHash) && !await AuthorizeAdminAsync("изменение PIN")) return;
var pin = await Task.Run(() => AdminPinService.Hash(newPin));
_state.Settings.AdminPinSalt = pin.Salt;
_state.Settings.AdminPinHash = pin.Hash;
_state.Settings.AdminPinIterations = pin.Iterations;
NewAdminPinBox.Password = "";
await _store.SaveAsync(_state);
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
}
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
CopySettingsFromControls();
await _store.SaveAsync(_state);
ApplyStartupSetting();
ConnectScanner();
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
}
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);
}
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.MaximumShiftHours = double.IsNaN(MaximumShiftHoursBox.Value) ? 20 : Math.Clamp((int)MaximumShiftHoursBox.Value, 1, 48);
_state.Settings.ShowEmployeeNamesOnTerminal = ShowEmployeeNamesSwitch.IsOn;
_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);
MaximumShiftHoursBox.Value = Math.Clamp(_state.Settings.MaximumShiftHours, 1, 48);
ShowEmployeeNamesSwitch.IsOn = _state.Settings.ShowEmployeeNamesOnTerminal;
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)
.Select(item => _state.Settings.ShowEmployeeNamesOnTerminal ? item : new AttendanceRecord
{
Id = item.Id,
EmployeeId = item.EmployeeId,
EmployeeName = MaskName(item.EmployeeName),
OccurredAtUtc = item.OccurredAtUtc,
ReaderIndex = item.ReaderIndex,
EventType = item.EventType,
NotificationDelivered = item.NotificationDelivered,
NotificationError = item.NotificationError
})
.ToList();
}
private static string MaskName(string name) => string.Join(' ', name
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Length == 1 ? $"{part[0]}…" : $"{part[0]}***"));
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());
private void EnqueueTask(Func<Task> operation) => App.DispatcherQueue.TryEnqueue(async () =>
{
try
{
await operation();
}
catch (Exception exception)
{
CrashLogService.Write(exception);
SetStatus($"Операция не выполнена: {exception.Message}", InfoBarSeverity.Error);
}
});
private async void SaveStateInBackground()
{
try
{
await _store.SaveAsync(_state);
}
catch (Exception exception)
{
CrashLogService.Write(exception);
SetStatus($"Не удалось сохранить данные: {exception.Message}", InfoBarSeverity.Error);
}
}
}