krona-staff/apps/KronaStaffTerminal/MainPage.xaml.cs
Ochenstarik d45bee960a
Some checks are pending
ci / verify (push) Waiting to run
fix: harden terminal recovery and deployment
2026-09-18 11:10:28 +07:00

991 lines
45 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 string? _registrationEmployeeId;
private bool _refreshingLists;
private string _lastAdminActor = "unknown";
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;
if (_store.RecoveryNotice is not null)
{
SetStatus(_store.RecoveryNotice, InfoBarSeverity.Warning);
}
}
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, createRecoveryPoint: true);
_lastAdminActor = "initial-pin";
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, createRecoveryPoint: true);
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 => "Женский",
_ => ""
};
await _store.SaveAsync(_state, createRecoveryPoint: true);
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 (_refreshingLists) return;
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, createRecoveryPoint: true);
RefreshLists();
TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован";
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, createRecoveryPoint: true);
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, createRecoveryPoint: true);
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, createRecoveryPoint: true);
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)
{
_lastAdminActor = $"employee:{employee.Id}";
TerminalMessage.Text = $"Администратор {TerminalDisplayName(employee)} подтверждён";
_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 = $"{TerminalDisplayName(employee)}\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 = $"{TerminalDisplayName(employee)}\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, createRecoveryPoint: true);
RefreshLists();
TerminalMessage.Text = "Журнал отметок очищен";
SetStatus("Журнал прихода и ухода очищен.", InfoBarSeverity.Success);
}
private async void ExportAttendance_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("экспорт журнала")) return;
var warning = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Экспорт персональных данных",
Content = "CSV-файл не шифруется и содержит ФИО, табельные номера, должности и время отметок. Сохраняйте его только в защищённом месте.",
PrimaryButtonText = "Продолжить экспорт",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await warning.ShowAsync() != ContentDialogResult.Primary) return;
_lastAdminActor = "unknown";
if (!await AuthorizeAdminAsync("экспорт персональных данных", requireIdentifiedActor: true)) 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;
AppendSecurityAudit("attendance.export", "started", $"records={_state.Attendance.Count}; file={file.Name}");
await _store.SaveAsync(_state);
try
{
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);
AppendSecurityAudit("attendance.export", "succeeded", $"records={_state.Attendance.Count}; file={file.Name}");
await _store.SaveAsync(_state);
SetStatus($"Журнал экспортирован: {file.Name}", InfoBarSeverity.Success);
}
catch (Exception exception)
{
AppendSecurityAudit("attendance.export", "failed", $"records={_state.Attendance.Count}; error={exception.GetType().Name}");
await _store.SaveAsync(_state);
CrashLogService.Write(exception);
SetStatus($"Экспорт не выполнен: {exception.Message}", InfoBarSeverity.Error);
}
}
private static string Csv(string value) => $"\"{value.Replace("\"", "\"\"")}\"";
public async Task<bool> AuthorizeExitAsync()
{
if (_stateLoadFailed)
{
SetStatus("Хранилище не открыто. Программа завершает работу без изменения данных.", InfoBarSeverity.Warning);
return true;
}
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, bool requireIdentifiedActor = false)
{
var remainingLockout = _state.Settings.AdminPinBlockedUntilUtc - DateTimeOffset.UtcNow;
var pinBlocked = remainingLockout > TimeSpan.Zero;
var hasAdministratorFingerprint = AdministratorFingerprintConfigured;
if (requireIdentifiedActor && !hasAdministratorFingerprint)
{
SetStatus("Для экспорта персональных данных требуется отпечаток сотрудника-администратора.", InfoBarSeverity.Warning);
return false;
}
if (pinBlocked && !hasAdministratorFingerprint)
{
SetStatus($"Слишком много неверных попыток PIN. Повторите через {Math.Ceiling(remainingLockout.TotalSeconds)} сек.", InfoBarSeverity.Warning);
return false;
}
var pinBox = new PasswordBox
{
Header = "PIN администратора",
PasswordRevealMode = PasswordRevealMode.Peek,
MinWidth = 280,
IsEnabled = !pinBlocked && !requireIdentifiedActor
};
var content = new StackPanel { Spacing = 12, MinWidth = 320 };
content.Children.Add(new TextBlock
{
Text = requireIdentifiedActor
? "Для записи автора экспорта приложите зарегистрированный палец сотрудника-администратора."
: hasAdministratorFingerprint
? pinBlocked
? $"PIN временно заблокирован ещё на {Math.Ceiling(remainingLockout.TotalSeconds)} сек. Приложите зарегистрированный палец администратора."
: "Введите 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",
IsPrimaryButtonEnabled = !pinBlocked && !requireIdentifiedActor,
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Primary
};
var dialogResult = await _adminAuthDialog.ShowAsync();
var fingerprintAuthorized = _adminAuthCompletion.Task.IsCompletedSuccessfully &&
_adminAuthCompletion.Task.Result;
_adminAuthCompletion.TrySetResult(false);
var pinAuthorized = false;
string verifiedPin = pinBox.Password;
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{
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;
var securityStateChanged = false;
if (authorized)
{
_state.Settings.FailedAdminPinAttempts = 0;
_state.Settings.AdminPinBlockedUntilUtc = default;
securityStateChanged = true;
if (pinAuthorized)
{
_lastAdminActor = "local-pin";
if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations)
{
if (!AdminPinService.Verify(verifiedPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations))
{
verifiedPin = verifiedPin.Trim();
}
var upgraded = await Task.Run(() => AdminPinService.Hash(verifiedPin));
_state.Settings.AdminPinSalt = upgraded.Salt;
_state.Settings.AdminPinHash = upgraded.Hash;
_state.Settings.AdminPinIterations = upgraded.Iterations;
}
}
}
else if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
{
_state.Settings.FailedAdminPinAttempts++;
var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10));
_state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
securityStateChanged = true;
}
if (securityStateChanged) await _store.SaveAsync(_state);
_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, createRecoveryPoint: true);
SetStatus("Новый PIN администратора сохранён.", InfoBarSeverity.Success);
}
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
{
if (!await EnsureAdminSessionAsync("изменение настроек")) return;
CopySettingsFromControls();
await _store.SaveAsync(_state, createRecoveryPoint: true);
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()
{
var selectedEmployeeId = (EmployeesList.SelectedItem as EmployeeRecord)?.Id;
_refreshingLists = true;
try
{
var employees = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
EmployeesList.ItemsSource = employees;
EmployeesList.SelectedItem = employees.FirstOrDefault(employee => employee.Id == selectedEmployeeId);
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();
}
finally
{
_refreshingLists = false;
}
}
private string TerminalDisplayName(EmployeeRecord employee) =>
_state.Settings.ShowEmployeeNamesOnTerminal ? employee.DisplayName : MaskName(employee.DisplayName);
private void AppendSecurityAudit(string action, string outcome, string detail) =>
_state.SecurityAudit.Add(new TerminalAuditRecord
{
Action = action,
Actor = _lastAdminActor,
Outcome = outcome,
Detail = detail
});
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);
}
}
}