feat(terminal): wire attendance tracker, tray states, fingerprint enrollment wizard, and re-authorization
This commit is contained in:
parent
fecf7e5911
commit
71660745a5
3 changed files with 364 additions and 154 deletions
|
|
@ -86,7 +86,7 @@ public partial class App : Application
|
|||
|
||||
Window = new MainWindow();
|
||||
DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
|
||||
_trayService = new TrayService(ShowWindow, RequestExit);
|
||||
_trayService = new TrayService(ShowWindow, RequestExit, ShowWindow, RequestSync);
|
||||
_activationEvent = new EventWaitHandle(false, EventResetMode.AutoReset, ActivationEventName);
|
||||
_activationCancellation = new CancellationTokenSource();
|
||||
_activationTask = Task.Run(() => ListenForActivation(_activationCancellation.Token));
|
||||
|
|
@ -123,6 +123,9 @@ public partial class App : Application
|
|||
|
||||
public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon();
|
||||
|
||||
public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) =>
|
||||
_trayService?.UpdateState(state, pendingCount, statusText);
|
||||
|
||||
private static void ShowWindow()
|
||||
{
|
||||
if (Window is MainWindow mainWindow)
|
||||
|
|
@ -131,6 +134,14 @@ public partial class App : Application
|
|||
}
|
||||
}
|
||||
|
||||
private static void RequestSync()
|
||||
{
|
||||
if (Window is MainWindow mainWindow && mainWindow.Content is FrameworkElement fe && fe.FindName("RootFrame") is Microsoft.UI.Xaml.Controls.Frame frame && frame.Content is MainPage page)
|
||||
{
|
||||
_ = page.SyncPendingAttendanceAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequestExit()
|
||||
{
|
||||
if (Window is MainWindow mainWindow)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ public sealed partial class MainPage : Page
|
|||
private readonly TerminalStore _store = new();
|
||||
private readonly NotificationService _notifications = new();
|
||||
private readonly AttendanceSyncService _attendanceSync = new();
|
||||
private readonly AttendanceTracker _attendanceTracker = new();
|
||||
private readonly TerminalIpcClient _ipcClient = new();
|
||||
private readonly FingerprintScanner _scanner = new();
|
||||
private readonly IUpdateSource _updateSource = new ForgejoUpdateSource();
|
||||
private TerminalState _state = new();
|
||||
|
|
@ -37,6 +39,7 @@ public sealed partial class MainPage : Page
|
|||
private int? _attendanceSuppressedFingerprintId;
|
||||
private DateTimeOffset _attendanceSuppressedUntilUtc;
|
||||
private string? _registrationEmployeeId;
|
||||
private string _registrationFingerLabel = "Отпечаток";
|
||||
private bool _refreshingLists;
|
||||
private bool _applicationLocked = true;
|
||||
private string _lastAdminActor = "unknown";
|
||||
|
|
@ -55,7 +58,8 @@ public sealed partial class MainPage : Page
|
|||
throw;
|
||||
}
|
||||
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
|
||||
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3"));
|
||||
_scanner.StateChanged += (state, message) => Enqueue(() => HandleScannerStateChanged(state, 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);
|
||||
|
|
@ -234,25 +238,33 @@ public sealed partial class MainPage : Page
|
|||
}
|
||||
|
||||
var enteredPin = UnlockPinBox.Password;
|
||||
var authorized = await Task.Run(() =>
|
||||
AdminPinService.Verify(enteredPin, _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations) ||
|
||||
(!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
|
||||
AdminPinService.Verify(enteredPin.Trim(), _state.Settings.AdminPinSalt, _state.Settings.AdminPinHash, _state.Settings.AdminPinIterations)));
|
||||
var authorized = AdminPinService.VerifyAndRecord(
|
||||
enteredPin,
|
||||
_state.Settings,
|
||||
DateTimeOffset.UtcNow,
|
||||
out var errorMessage,
|
||||
out var rehashNeeded);
|
||||
|
||||
if (!authorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
authorized = AdminPinService.VerifyAndRecord(
|
||||
enteredPin.Trim(),
|
||||
_state.Settings,
|
||||
DateTimeOffset.UtcNow,
|
||||
out errorMessage,
|
||||
out rehashNeeded);
|
||||
}
|
||||
|
||||
await _store.SaveAsync(_state);
|
||||
UnlockPinBox.Password = "";
|
||||
|
||||
if (authorized)
|
||||
{
|
||||
_state.Settings.FailedAdminPinAttempts = 0;
|
||||
_state.Settings.AdminPinBlockedUntilUtc = default;
|
||||
await _store.SaveAsync(_state);
|
||||
UnlockApplication("local-pin", "Вход выполнен по PIN администратора.");
|
||||
return;
|
||||
}
|
||||
|
||||
_state.Settings.FailedAdminPinAttempts++;
|
||||
var delaySeconds = Math.Min(900, 1 << Math.Min(_state.Settings.FailedAdminPinAttempts - 1, 10));
|
||||
_state.Settings.AdminPinBlockedUntilUtc = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
|
||||
UnlockPinBox.Password = "";
|
||||
LockScreenMessage.Text = $"Неверный PIN. Следующая попытка через {delaySeconds} сек.";
|
||||
await _store.SaveAsync(_state);
|
||||
LockScreenMessage.Text = errorMessage ?? "Неверный PIN.";
|
||||
}
|
||||
|
||||
private void LockAdminSession(string message)
|
||||
|
|
@ -344,6 +356,24 @@ public sealed partial class MainPage : Page
|
|||
return true;
|
||||
}
|
||||
|
||||
private void HandleScannerStateChanged(ScannerState state, string message)
|
||||
{
|
||||
SetStatus(message, state switch
|
||||
{
|
||||
ScannerState.Connected => InfoBarSeverity.Success,
|
||||
ScannerState.Busy => InfoBarSeverity.Informational,
|
||||
ScannerState.DriverError => InfoBarSeverity.Error,
|
||||
_ => InfoBarSeverity.Warning
|
||||
});
|
||||
|
||||
var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0;
|
||||
var trayState = state == ScannerState.Connected
|
||||
? (_state?.Attendance?.Any(a => !a.ServerSynced && a.ServerSyncError != null) == true ? TrayState.NoServer : TrayState.Normal)
|
||||
: TrayState.NoScanner;
|
||||
|
||||
((App)Application.Current).UpdateTray(trayState, pendingCount, message);
|
||||
}
|
||||
|
||||
private void ConnectScanner()
|
||||
{
|
||||
try
|
||||
|
|
@ -356,11 +386,12 @@ public sealed partial class MainPage : Page
|
|||
try
|
||||
{
|
||||
ReaderCombo.Items.Clear();
|
||||
for (var index = 0; index < _scanner.DeviceCount; index++)
|
||||
foreach (var device in _scanner.DiscoveredDevices)
|
||||
{
|
||||
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
|
||||
ReaderCombo.Items.Add(device.DisplayName);
|
||||
}
|
||||
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
|
||||
var selectedIdx = _scanner.DiscoveredDevices.ToList().FindIndex(d => d.Index == _scanner.ReaderIndex);
|
||||
ReaderCombo.SelectedIndex = selectedIdx >= 0 ? selectedIdx : 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
|
@ -371,16 +402,23 @@ public sealed partial class MainPage : Page
|
|||
_state.Settings.ReaderIndex = _scanner.ReaderIndex;
|
||||
SaveStateInBackground();
|
||||
}
|
||||
|
||||
var activeDevice = _scanner.DiscoveredDevices.FirstOrDefault(d => d.Index == _scanner.ReaderIndex);
|
||||
var deviceName = activeDevice?.DisplayName ?? $"SLK20R №{_scanner.ReaderIndex + 1}";
|
||||
|
||||
if (_scanner.SkippedTemplateCount > 0)
|
||||
{
|
||||
SetStatus(
|
||||
$"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
|
||||
$"Сканер {deviceName} подключён. Не удалось загрузить отпечатков: {_scanner.SkippedTemplateCount}. Перерегистрируйте их в разделе сотрудников.",
|
||||
InfoBarSeverity.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
|
||||
SetStatus($"Сканер {deviceName} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
var pendingCount = _state.Attendance.Count(a => !a.ServerSynced);
|
||||
((App)Application.Current).UpdateTray(TrayState.Normal, pendingCount, $"Сканер {deviceName} подключён");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
|
|
@ -395,6 +433,8 @@ public sealed partial class MainPage : Page
|
|||
_refreshingReaders = false;
|
||||
}
|
||||
SetStatus($"{exception.Message} Проверьте USB-подключение и нажмите «Найти сканеры».", InfoBarSeverity.Error);
|
||||
var pendingCount = _state?.Attendance?.Count(a => !a.ServerSynced) ?? 0;
|
||||
((App)Application.Current).UpdateTray(TrayState.NoScanner, pendingCount, "Сканер не подключён");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -634,6 +674,7 @@ public sealed partial class MainPage : Page
|
|||
DefaultButton = ContentDialogButton.Close
|
||||
};
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
if (!await AuthorizeAdminAsync("деактивация сотрудника")) return;
|
||||
|
||||
employee.IsBlocked = true;
|
||||
employee.BlockedAtUtc = DateTimeOffset.UtcNow;
|
||||
|
|
@ -670,6 +711,54 @@ public sealed partial class MainPage : Page
|
|||
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var fingerCombo = new ComboBox
|
||||
{
|
||||
Header = "Выберите палец для регистрации",
|
||||
ItemsSource = new[]
|
||||
{
|
||||
"Указательный (правая рука)",
|
||||
"Большой (правая рука)",
|
||||
"Средний (правая рука)",
|
||||
"Безымянный (правая рука)",
|
||||
"Мизинец (правая рука)",
|
||||
"Указательный (левая рука)",
|
||||
"Большой (левая рука)",
|
||||
"Средний (левая рука)",
|
||||
"Безымянный (левая рука)",
|
||||
"Мизинец (левая рука)",
|
||||
"Дополнительный отпечаток"
|
||||
},
|
||||
SelectedIndex = 0,
|
||||
MinWidth = 280
|
||||
};
|
||||
|
||||
var wizardDialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = $"Мастер регистрации отпечатка: {employee.DisplayName}",
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Шаг 1 из 3: Выберите палец для сканирования.\nПосле нажатия «Начать» приложите палец к сканеру 3 раза.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
fingerCombo
|
||||
}
|
||||
},
|
||||
PrimaryButtonText = "Начать регистрацию",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
if (await wizardDialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
|
||||
_registrationFingerLabel = (string)fingerCombo.SelectedItem;
|
||||
|
||||
var fingerprintId = _state.Employees
|
||||
.SelectMany(item => item.Fingerprints)
|
||||
.Select(item => item.Id)
|
||||
|
|
@ -677,6 +766,7 @@ public sealed partial class MainPage : Page
|
|||
.Max() + 1;
|
||||
_registrationEmployeeId = employee.Id;
|
||||
_scanner.BeginRegistration(fingerprintId);
|
||||
SetStatus($"Мастер регистрации: приложите {_registrationFingerLabel} к сканеру 3 раза.", InfoBarSeverity.Informational);
|
||||
}
|
||||
|
||||
private void CancelRegistration_Click(object sender, RoutedEventArgs e)
|
||||
|
|
@ -709,17 +799,31 @@ public sealed partial class MainPage : Page
|
|||
{
|
||||
var employee = _state.Employees.FirstOrDefault(item => item.Id == _registrationEmployeeId);
|
||||
if (employee is null) return;
|
||||
|
||||
var existingSlot = employee.Fingerprints.FirstOrDefault(f => f.Label == _registrationFingerLabel);
|
||||
if (existingSlot is not null)
|
||||
{
|
||||
existingSlot.Id = fingerprintId;
|
||||
existingSlot.Template = template;
|
||||
existingSlot.EnrolledAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
employee.Fingerprints.Add(new FingerprintRecord
|
||||
{
|
||||
Id = fingerprintId,
|
||||
Template = template,
|
||||
Label = $"Отпечаток {employee.Fingerprints.Count + 1}"
|
||||
Label = _registrationFingerLabel,
|
||||
EnrolledAtUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
_registrationEmployeeId = null;
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"Новый отпечаток сотрудника «{TerminalDisplayName(employee)}» зарегистрирован";
|
||||
SetStatus($"Добавлен отпечаток №{employee.Fingerprints.Count} сотрудника «{employee.DisplayName}».", InfoBarSeverity.Success);
|
||||
ConnectScanner();
|
||||
TerminalMessage.Text = $"Отпечаток «{_registrationFingerLabel}» сотрудника «{TerminalDisplayName(employee)}» успешно сохранён";
|
||||
SetStatus($"Отпечаток «{_registrationFingerLabel}» сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
private async void DeleteFingerprints_Click(object sender, RoutedEventArgs e)
|
||||
|
|
@ -742,23 +846,45 @@ public sealed partial class MainPage : Page
|
|||
return;
|
||||
}
|
||||
|
||||
var options = new List<string> { "Все зарегистрированные отпечатки" };
|
||||
options.AddRange(employee.Fingerprints.Select(f => $"{f.Label} (ID {f.Id})"));
|
||||
|
||||
var combo = new ComboBox
|
||||
{
|
||||
Header = "Выберите отпечаток для удаления",
|
||||
ItemsSource = options,
|
||||
SelectedIndex = 0,
|
||||
MinWidth = 280
|
||||
};
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = XamlRoot,
|
||||
Title = "Удалить все отпечатки сотрудника?",
|
||||
Content = $"{employee.DisplayName}: будет удалено отпечатков — {employee.Fingerprints.Count}. Сам сотрудник останется.",
|
||||
PrimaryButtonText = "Удалить отпечатки",
|
||||
Title = $"Удаление отпечатков: {employee.DisplayName}",
|
||||
Content = new StackPanel { Spacing = 10, Children = { combo } },
|
||||
PrimaryButtonText = "Удалить",
|
||||
CloseButtonText = "Отмена",
|
||||
DefaultButton = ContentDialogButton.Close
|
||||
};
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
if (!await AuthorizeAdminAsync("удаление отпечатков сотрудника")) return;
|
||||
|
||||
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
|
||||
if (!await AuthorizeAdminAsync("удаление отпечатка сотрудника")) return;
|
||||
|
||||
if (combo.SelectedIndex == 0)
|
||||
{
|
||||
employee.Fingerprints.Clear();
|
||||
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
var chosenFingerprint = employee.Fingerprints[combo.SelectedIndex - 1];
|
||||
employee.Fingerprints.RemoveAt(combo.SelectedIndex - 1);
|
||||
SetStatus($"Отпечаток «{chosenFingerprint.Label}» сотрудника «{employee.DisplayName}» удален.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
await _store.SaveAsync(_state, createRecoveryPoint: true);
|
||||
RefreshLists();
|
||||
ConnectScanner();
|
||||
SetStatus($"Все отпечатки сотрудника «{employee.DisplayName}» удалены.", InfoBarSeverity.Success);
|
||||
}
|
||||
|
||||
private async void ToggleAdministrator_Click(object sender, RoutedEventArgs e)
|
||||
|
|
@ -878,50 +1004,41 @@ public sealed partial class MainPage : Page
|
|||
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 result = await _attendanceTracker.TryRecordAttendanceAsync(
|
||||
_state,
|
||||
employee,
|
||||
_scanner.ReaderIndex,
|
||||
now,
|
||||
state => _store.SaveAsync(state));
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
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);
|
||||
if (result.IsDuplicateSuppressed)
|
||||
{
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\nПовторный скан отклонён\nПовторите через {result.RemainingSeconds} сек.";
|
||||
SetStatus(result.Message, 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);
|
||||
var record = result.Record!;
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{eventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||||
SetStatus($"{eventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
|
||||
TerminalMessage.Text = $"{TerminalDisplayName(employee)}\n{record.EventType} отмечен в {now.ToLocalTime():HH:mm}";
|
||||
SetStatus($"{record.EventType} отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
|
||||
|
||||
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, eventType, now.ToLocalTime());
|
||||
var delivery = await _notifications.SendAttendanceAsync(_state.Settings, employee, record.EventType, now.ToLocalTime());
|
||||
record.NotificationDelivered = delivery.Delivered;
|
||||
record.NotificationError = delivery.Error;
|
||||
|
||||
// Try IPC enqueue first for background service
|
||||
_ = _ipcClient.EnqueueAttendanceAsync(record);
|
||||
|
||||
await SyncPendingAttendanceAsync();
|
||||
await _store.SaveAsync(_state);
|
||||
RefreshLists();
|
||||
if (delivery.Error is not null)
|
||||
{
|
||||
SetStatus($"{eventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
|
||||
SetStatus($"{record.EventType} сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -933,63 +1050,43 @@ public sealed partial class MainPage : Page
|
|||
return;
|
||||
}
|
||||
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var hour = Math.Clamp(_state.Settings.AutomaticCheckoutHour, 0, 23);
|
||||
var minute = Math.Clamp(_state.Settings.AutomaticCheckoutMinute, 0, 59);
|
||||
var latestScheduleUtc = LatestAutomaticCheckoutScheduleUtc(nowUtc, hour, minute);
|
||||
if (_state.Settings.LastAutomaticCheckoutUtc >= latestScheduleUtc) return;
|
||||
|
||||
_automaticCheckoutInProgress = true;
|
||||
try
|
||||
{
|
||||
var automaticRecords = new List<(AttendanceRecord Record, EmployeeRecord Employee)>();
|
||||
foreach (var employee in _state.Employees)
|
||||
{
|
||||
var last = _state.Attendance
|
||||
.Where(item => item.EmployeeId == employee.Id)
|
||||
.OrderByDescending(item => item.OccurredAtUtc)
|
||||
.FirstOrDefault();
|
||||
if (last is null || last.EventType != "Приход") continue;
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var created = await _attendanceTracker.RunScheduledCheckoutAsync(
|
||||
_state,
|
||||
nowUtc,
|
||||
state => _store.SaveAsync(state));
|
||||
|
||||
var checkoutUtc = NextAutomaticCheckoutAfter(last.OccurredAtUtc, hour, minute);
|
||||
if (checkoutUtc > nowUtc) continue;
|
||||
automaticRecords.Add((new AttendanceRecord
|
||||
{
|
||||
EmployeeId = employee.Id,
|
||||
EmployeeName = employee.DisplayName,
|
||||
OccurredAtUtc = checkoutUtc,
|
||||
ReaderIndex = -1,
|
||||
EventType = "Уход автоматически"
|
||||
}, employee));
|
||||
}
|
||||
if (created.Count == 0) return;
|
||||
|
||||
_state.Attendance.InsertRange(0, automaticRecords.Select(item => item.Record));
|
||||
if (_state.Attendance.Count > 100_000)
|
||||
{
|
||||
_state.Attendance.RemoveRange(100_000, _state.Attendance.Count - 100_000);
|
||||
}
|
||||
_state.Settings.LastAutomaticCheckoutUtc = latestScheduleUtc;
|
||||
AppendSecurityAudit(
|
||||
"attendance.automatic_checkout",
|
||||
"succeeded",
|
||||
$"scheduled={latestScheduleUtc:O}; records={automaticRecords.Count}");
|
||||
await _store.SaveAsync(_state);
|
||||
$"scheduled={nowUtc:O}; records={created.Count}");
|
||||
|
||||
RefreshLists();
|
||||
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {created.Count}";
|
||||
SetStatus($"Автоматически закрыто смен: {created.Count}.", InfoBarSeverity.Informational);
|
||||
|
||||
if (automaticRecords.Count == 0) return;
|
||||
TerminalMessage.Text = $"Автоматический уход\nЗакрыто смен: {automaticRecords.Count}";
|
||||
SetStatus($"Автоматически закрыто смен: {automaticRecords.Count}.", InfoBarSeverity.Informational);
|
||||
|
||||
await Task.WhenAll(automaticRecords.Select(async item =>
|
||||
foreach (var record in created)
|
||||
{
|
||||
_ = _ipcClient.EnqueueAttendanceAsync(record);
|
||||
var employee = _state.Employees.FirstOrDefault(e => e.Id == record.EmployeeId);
|
||||
if (employee is not null)
|
||||
{
|
||||
var delivery = await _notifications.SendAttendanceAsync(
|
||||
_state.Settings,
|
||||
item.Employee,
|
||||
item.Record.EventType,
|
||||
item.Record.OccurredAtUtc.ToLocalTime());
|
||||
item.Record.NotificationDelivered = delivery.Delivered;
|
||||
item.Record.NotificationError = delivery.Error;
|
||||
}));
|
||||
employee,
|
||||
record.EventType,
|
||||
record.OccurredAtUtc.ToLocalTime());
|
||||
record.NotificationDelivered = delivery.Delivered;
|
||||
record.NotificationError = delivery.Error;
|
||||
}
|
||||
}
|
||||
|
||||
await SyncPendingAttendanceAsync();
|
||||
await _store.SaveAsync(_state);
|
||||
RefreshLists();
|
||||
}
|
||||
|
|
@ -1186,58 +1283,48 @@ public sealed partial class MainPage : Page
|
|||
// PasswordBox is a WinUI object and must only be accessed from the UI
|
||||
// thread. Capture its value before performing PBKDF2 verification.
|
||||
var enteredPin = pinBox.Password;
|
||||
string verifiedPin = enteredPin;
|
||||
string? pinErrorMessage = null;
|
||||
var securityStateChanged = false;
|
||||
|
||||
if (dialogResult == ContentDialogResult.Primary && !pinBlocked && !requireIdentifiedActor)
|
||||
{
|
||||
pinAuthorized = await Task.Run(() =>
|
||||
AdminPinService.Verify(
|
||||
pinAuthorized = AdminPinService.VerifyAndRecord(
|
||||
enteredPin,
|
||||
_state.Settings.AdminPinSalt,
|
||||
_state.Settings.AdminPinHash,
|
||||
_state.Settings.AdminPinIterations) ||
|
||||
(!string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal) &&
|
||||
AdminPinService.Verify(
|
||||
enteredPin.Trim(),
|
||||
_state.Settings.AdminPinSalt,
|
||||
_state.Settings.AdminPinHash,
|
||||
_state.Settings.AdminPinIterations)));
|
||||
}
|
||||
var authorized = fingerprintAuthorized || pinAuthorized;
|
||||
var securityStateChanged = false;
|
||||
if (authorized)
|
||||
_state.Settings,
|
||||
DateTimeOffset.UtcNow,
|
||||
out pinErrorMessage,
|
||||
out _);
|
||||
|
||||
if (!pinAuthorized && !string.Equals(enteredPin, enteredPin.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
_state.Settings.FailedAdminPinAttempts = 0;
|
||||
_state.Settings.AdminPinBlockedUntilUtc = default;
|
||||
pinAuthorized = AdminPinService.VerifyAndRecord(
|
||||
enteredPin.Trim(),
|
||||
_state.Settings,
|
||||
DateTimeOffset.UtcNow,
|
||||
out pinErrorMessage,
|
||||
out _);
|
||||
}
|
||||
securityStateChanged = true;
|
||||
if (pinAuthorized)
|
||||
{
|
||||
_lastAdminActor = "local-pin";
|
||||
if (_state.Settings.AdminPinIterations < AdminPinService.CurrentIterations)
|
||||
}
|
||||
}
|
||||
else if (fingerprintAuthorized)
|
||||
{
|
||||
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);
|
||||
_state.Settings.FailedAdminPinAttempts = 0;
|
||||
_state.Settings.AdminPinBlockedUntilUtc = default;
|
||||
securityStateChanged = true;
|
||||
}
|
||||
|
||||
var authorized = fingerprintAuthorized || pinAuthorized;
|
||||
if (securityStateChanged) await _store.SaveAsync(_state);
|
||||
_adminAuthCompletion = null;
|
||||
_adminAuthDialog = null;
|
||||
|
||||
SetStatus(authorized
|
||||
? fingerprintAuthorized ? "Вход выполнен по отпечатку администратора." : "Вход выполнен по PIN администратора."
|
||||
: dialogResult == ContentDialogResult.Primary ? "Неверный PIN администратора." : "Вход отменён.",
|
||||
: dialogResult == ContentDialogResult.Primary ? (pinErrorMessage ?? "Неверный PIN администратора.") : "Вход отменён.",
|
||||
authorized ? InfoBarSeverity.Success : dialogResult == ContentDialogResult.Primary ? InfoBarSeverity.Error : InfoBarSeverity.Warning);
|
||||
return authorized;
|
||||
}
|
||||
|
|
@ -1464,19 +1551,41 @@ public sealed partial class MainPage : Page
|
|||
CurrentVersionText.Text = $"Текущая версия: {ForgejoUpdateSource.CurrentVersion.ToString(3)}";
|
||||
}
|
||||
|
||||
private async Task SyncPendingAttendanceAsync()
|
||||
public async Task SyncPendingAttendanceAsync()
|
||||
{
|
||||
if (_attendanceSyncInProgress || _stateLoadFailed) return;
|
||||
_attendanceSyncInProgress = true;
|
||||
try
|
||||
{
|
||||
var serviceAvailable = await _ipcClient.IsServiceAvailableAsync();
|
||||
if (serviceAvailable)
|
||||
{
|
||||
var ipcResult = await _ipcClient.TriggerSyncAsync();
|
||||
_state = await _store.LoadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await _attendanceSync.SyncPendingAsync(_state);
|
||||
await _store.SaveAsync(_state);
|
||||
}
|
||||
|
||||
var pendingCount = _state.Attendance.Count(a => !a.ServerSynced);
|
||||
var trayState = !_scanner.IsConnected
|
||||
? TrayState.NoScanner
|
||||
: (_state.Attendance.Any(a => !a.ServerSynced && a.ServerSyncError != null)
|
||||
? TrayState.NoServer
|
||||
: TrayState.Normal);
|
||||
|
||||
((App)Application.Current).UpdateTray(trayState, pendingCount);
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
SetStatus(exception.Message, InfoBarSeverity.Warning);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CrashLogService.Write(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_attendanceSyncInProgress = false;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ using System.Runtime.InteropServices;
|
|||
|
||||
namespace KronaStaffTerminal.Services;
|
||||
|
||||
public enum TrayState
|
||||
{
|
||||
Normal,
|
||||
NoScanner,
|
||||
NoServer,
|
||||
Error
|
||||
}
|
||||
|
||||
public sealed class TrayService : IDisposable
|
||||
{
|
||||
private const uint CallbackMessage = 0x8001;
|
||||
|
|
@ -22,20 +30,35 @@ public sealed class TrayService : IDisposable
|
|||
private const uint LrDefaultSize = 0x0040;
|
||||
private const uint OpenCommand = 1001;
|
||||
private const uint ExitCommand = 1002;
|
||||
private const uint StatusCommand = 1003;
|
||||
private const uint SyncCommand = 1004;
|
||||
|
||||
private readonly Action _showWindow;
|
||||
private readonly Action _requestExit;
|
||||
private readonly Action? _showStatus;
|
||||
private readonly Action? _requestSync;
|
||||
private readonly WindowProcedure _windowProcedure;
|
||||
private readonly IntPtr _windowHandle;
|
||||
private readonly IntPtr _iconHandle;
|
||||
private NotifyIconData _iconData;
|
||||
private bool _disposed;
|
||||
|
||||
public TrayService(Action showWindow, Action requestExit)
|
||||
private TrayState _currentState = TrayState.Normal;
|
||||
private int _pendingSyncCount;
|
||||
private string _currentStatusText = "Работает в штатном режиме";
|
||||
|
||||
public TrayService(
|
||||
Action showWindow,
|
||||
Action requestExit,
|
||||
Action? showStatus = null,
|
||||
Action? requestSync = null)
|
||||
{
|
||||
_showWindow = showWindow;
|
||||
_requestExit = requestExit;
|
||||
_showStatus = showStatus;
|
||||
_requestSync = requestSync;
|
||||
_windowProcedure = HandleWindowMessage;
|
||||
|
||||
var className = $"KronaStaffTerminal.Tray.{Environment.ProcessId}";
|
||||
var windowClass = new WindowClass
|
||||
{
|
||||
|
|
@ -51,12 +74,43 @@ public sealed class TrayService : IDisposable
|
|||
ShellNotifyIcon(NimAdd, ref _iconData);
|
||||
}
|
||||
|
||||
public void UpdateState(TrayState state, int pendingSyncCount, string? statusText = null)
|
||||
{
|
||||
_currentState = state;
|
||||
_pendingSyncCount = pendingSyncCount;
|
||||
if (!string.IsNullOrWhiteSpace(statusText))
|
||||
{
|
||||
_currentStatusText = statusText;
|
||||
}
|
||||
|
||||
var stateLabel = state switch
|
||||
{
|
||||
TrayState.Normal => "Работает",
|
||||
TrayState.NoScanner => "Сканер отключён",
|
||||
TrayState.NoServer => "Сервер недоступен",
|
||||
TrayState.Error => "Сбой",
|
||||
_ => "В работе"
|
||||
};
|
||||
|
||||
var tip = $"Krona Staff — {stateLabel} ({pendingSyncCount} в очереди)";
|
||||
if (tip.Length > 127) tip = tip[..127];
|
||||
|
||||
_iconData.Flags = NifTip | NifIcon;
|
||||
_iconData.Tip = tip;
|
||||
ShellNotifyIcon(NimModify, ref _iconData);
|
||||
}
|
||||
|
||||
public void ShowRunningBalloon()
|
||||
{
|
||||
ShowNotificationBalloon("Krona Staff", "Терминал продолжает работать в трее.", 1);
|
||||
}
|
||||
|
||||
public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1)
|
||||
{
|
||||
_iconData.Flags = NifInfo;
|
||||
_iconData.InfoTitle = "Krona Staff";
|
||||
_iconData.Info = "Терминал продолжает работать в трее.";
|
||||
_iconData.InfoFlags = 1;
|
||||
_iconData.InfoTitle = title.Length > 63 ? title[..63] : title;
|
||||
_iconData.Info = message.Length > 255 ? message[..255] : message;
|
||||
_iconData.InfoFlags = infoFlags;
|
||||
ShellNotifyIcon(NimModify, ref _iconData);
|
||||
}
|
||||
|
||||
|
|
@ -94,13 +148,49 @@ public sealed class TrayService : IDisposable
|
|||
{
|
||||
var menu = CreatePopupMenu();
|
||||
AppendMenu(menu, MfString, OpenCommand, "Открыть Krona Staff");
|
||||
|
||||
var statusSummary = _currentState switch
|
||||
{
|
||||
TrayState.Normal => "Состояние: Работает",
|
||||
TrayState.NoScanner => "Состояние: Сканер отключён",
|
||||
TrayState.NoServer => "Состояние: Нет связи с сервером",
|
||||
TrayState.Error => "Состояние: Ошибка",
|
||||
_ => $"Состояние: {_currentStatusText}"
|
||||
};
|
||||
AppendMenu(menu, MfString, StatusCommand, statusSummary);
|
||||
|
||||
var syncSummary = _pendingSyncCount > 0
|
||||
? $"Синхронизация ({_pendingSyncCount} в очереди)"
|
||||
: "Синхронизация (очередь пуста)";
|
||||
AppendMenu(menu, MfString, SyncCommand, syncSummary);
|
||||
|
||||
AppendMenu(menu, MfSeparator, 0, null);
|
||||
AppendMenu(menu, MfString, ExitCommand, "Выход администратора…");
|
||||
|
||||
GetCursorPos(out var point);
|
||||
SetForegroundWindow(_windowHandle);
|
||||
var command = TrackPopupMenu(menu, TpmReturnCmd, point.X, point.Y, 0, _windowHandle, IntPtr.Zero);
|
||||
DestroyMenu(menu);
|
||||
|
||||
if (command == OpenCommand) Dispatch(_showWindow);
|
||||
if (command == StatusCommand)
|
||||
{
|
||||
if (_showStatus is not null)
|
||||
{
|
||||
Dispatch(_showStatus);
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowNotificationBalloon("Состояние терминала", _currentStatusText);
|
||||
}
|
||||
}
|
||||
if (command == SyncCommand)
|
||||
{
|
||||
if (_requestSync is not null)
|
||||
{
|
||||
Dispatch(_requestSync);
|
||||
}
|
||||
}
|
||||
if (command == ExitCommand) Dispatch(_requestExit);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue