feat: add SLK20R attendance terminal
Some checks are pending
ci / verify (push) Waiting to run

This commit is contained in:
Ochenstarik 2026-09-17 20:51:20 +07:00
parent 3b12831ebe
commit efd5bd5c60
28 changed files with 1157 additions and 0 deletions

View file

@ -23,3 +23,7 @@ docker compose up --build app postgres redis
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
Архитектурные решения: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), интеграция: [docs/PLATFORM-INTEGRATION.md](docs/PLATFORM-INTEGRATION.md), будущий объём: [docs/ROADMAP.md](docs/ROADMAP.md).
## Биометрический терминал
Windows-приложение для SLK20R находится в [apps/KronaStaffTerminal](apps/KronaStaffTerminal). Оно добавляет сотрудников, регистрирует отпечаток по трём сканам, отмечает первый приход за день и отправляет уведомление в Telegram и/или MAX. Инструкция по запуску и настройке находится в [README терминала](apps/KronaStaffTerminal/README.md).

54
apps/KronaStaffTerminal/.gitignore vendored Normal file
View file

@ -0,0 +1,54 @@
## .NET / Visual Studio
[Bb]in/
[Oo]bj/
[Dd]ebug/
[Rr]elease/
.vs/
*.user
*.suo
*.userosscache
*.sln.docstates
artifacts/
# Build logs
[Ll]og/
[Ll]ogs/
*.log
*.binlog
# Test results
[Tt]est[Rr]esult*/
*.trx
*.coverage
*.coveragexml
# NuGet
*.nupkg
*.snupkg
*.nuget.props
*.nuget.targets
project.lock.json
# MSIX packaging output
AppPackages/
BundleArtifacts/
*.msix
*.msixupload
*.appx
*.appxbundle
*.appxupload
# Publish output
publish/
*.pubxml
PublishScripts/
# Code analysis and tooling
_ReSharper*/
*.DotSettings.user
*.dotCover
.idea/
# Generated files
Generated\ Files/
*_wpftmp.csproj

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<Application
x:Class="KronaStaffTerminal.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:KronaStaffTerminal">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -0,0 +1,57 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace KronaStaffTerminal;
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : Application
{
/// <summary>
/// The main application window. Use <c>App.Window</c> from any class that needs
/// the window reference (for dialogs, pickers, interop, etc.).
/// </summary>
public static Window Window { get; private set; } = null!;
/// <summary>
/// The UI thread dispatcher. Use <c>App.DispatcherQueue</c> to marshal calls
/// to the UI thread. Fully qualified to avoid CS0104 ambiguity with
/// <see cref="Windows.System.DispatcherQueue"/>.
/// </summary>
public static Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; private set; } = null!;
/// <summary>
/// The native window handle (HWND). Use for file pickers,
/// <c>DataTransferManager</c>, and any WinRT interop that requires
/// <c>InitializeWithWindow</c>.
/// </summary>
public static nint WindowHandle =>
WinRT.Interop.WindowNative.GetWindowHandle(Window);
/// <summary>
/// Initializes the singleton application object.
/// </summary>
public App()
{
InitializeComponent();
}
/// <summary>
/// Invoked when the application is launched.
/// </summary>
/// <param name="args">Details about the launch request and process.</param>
protected override void OnLaunched(Microsoft.UI.Xaml.LaunchActivatedEventArgs args)
{
Window = new MainWindow();
DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread();
Window.Activate();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

View file

@ -0,0 +1,82 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<RootNamespace>KronaStaffTerminal</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Platforms>x86;x64;ARM64</Platforms>
<RuntimeIdentifier Condition="'$(RuntimeIdentifier)' == ''">win-$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant())</RuntimeIdentifier>
<PublishProfile Condition="Exists('Properties\PublishProfiles\win-$(Platform).pubxml')">win-$(Platform).pubxml</PublishProfile>
<UseWinUI>true</UseWinUI>
<WinUISDKReferences>false</WinUISDKReferences>
<EnableMsixTooling>true</EnableMsixTooling>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x64</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" />
<Content Include="Assets\Square150x150Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.scale-200.png" />
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
<Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\AppIcon.ico" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup>
<ItemGroup>
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup>
<Reference Include="libzkfpcsharp">
<HintPath>Vendor\ZKFinger\x64\libzkfpcsharp.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<!--
Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
Tools extension to be activated for this project even if the Windows App SDK Nuget
package has not yet been restored.
-->
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<!--
Microsoft.Windows.SDK.BuildTools.WinApp adds first-class support for
`dotnet run` on packaged WinUI apps: it hooks the .NET CLI Run target to
register a debug identity via the winapp CLI and launch the app with
package identity (AUMID).
Set <EnableWinAppRunSupport>false</EnableWinAppRunSupport> in a
<PropertyGroup> above to opt out.
-->
<ItemGroup>
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.28000.2705" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.5.1" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="10.0.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools.WinApp" Version="0.6.1" />
</ItemGroup>
<!--
Defining the "HasPackageAndPublishMenuAddedByProject" property here allows the Solution
Explorer "Package and Publish" context menu entry to be enabled for this project even if
the Windows App SDK Nuget package has not yet been restored.
-->
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
<!-- Publish Properties -->
<PropertyGroup>
<PublishReadyToRun Condition="'$(Configuration)' == 'Debug'">False</PublishReadyToRun>
<PublishReadyToRun Condition="'$(Configuration)' != 'Debug'">True</PublishReadyToRun>
<PublishTrimmed>False</PublishTrimmed>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<Page
x:Class="KronaStaffTerminal.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:KronaStaffTerminal.Models"
Loaded="Page_Loaded"
Unloaded="Page_Unloaded"
mc:Ignorable="d">
<Grid Padding="24,16,24,24" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<InfoBar x:Name="StatusBar"
IsOpen="True"
IsClosable="False"
Severity="Informational"
Title="Подключение"
Message="Запуск терминала…"/>
<TabView Grid.Row="1" IsAddTabButtonVisible="False">
<TabViewItem Header="Терминал" IsClosable="False">
<Grid Padding="4,16,4,4" ColumnSpacing="24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*"/>
<ColumnDefinition Width="3*"/>
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="16">
<FontIcon Glyph="&#xE928;" FontSize="88"/>
<TextBlock Text="Приложите палец" Style="{StaticResource TitleTextBlockStyle}" HorizontalAlignment="Center"/>
<TextBlock x:Name="TerminalMessage" Text="Ожидание сканирования" TextWrapping="Wrap" TextAlignment="Center" MaxWidth="360"/>
</StackPanel>
<Grid Grid.Column="1" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="Последние отметки" Style="{StaticResource SubtitleTextBlockStyle}"/>
<ListView x:Name="AttendanceList" Grid.Row="1" SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:AttendanceRecord">
<Grid Padding="4,10" ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="170"/>
<ColumnDefinition Width="130"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind EmployeeName}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{x:Bind LocalTime}"/>
<TextBlock Grid.Column="2" Text="{x:Bind DeliveryStatus}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</Grid>
</TabViewItem>
<TabViewItem Header="Сотрудники" IsClosable="False">
<Grid Padding="4,16,4,4" RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<TextBlock Text="Добавление и регистрация сотрудников" Style="{StaticResource SubtitleTextBlockStyle}"/>
<CommandBar Grid.Row="1" DefaultLabelPosition="Right">
<AppBarButton Icon="Add" Label="Добавить" Click="AddEmployee_Click"/>
<AppBarButton Icon="Contact" Label="Зарегистрировать отпечаток" Click="RegisterFingerprint_Click"/>
<AppBarButton Icon="Delete" Label="Удалить" Click="DeleteEmployee_Click"/>
</CommandBar>
<Grid Grid.Row="2" ColumnSpacing="20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Spacing="10">
<TextBox x:Name="PersonnelNumberBox" Header="Табельный номер" PlaceholderText="Например, 001" AutomationProperties.Name="Табельный номер"/>
<TextBox x:Name="EmployeeNameBox" Header="Имя сотрудника" PlaceholderText="Иван Петров" AutomationProperties.Name="Имя сотрудника"/>
<TextBlock Text="Выберите сотрудника справа и нажмите «Зарегистрировать отпечаток». Сканер попросит приложить один палец три раза." TextWrapping="Wrap" Opacity="0.75"/>
</StackPanel>
<ListView x:Name="EmployeesList" Grid.Column="1" SelectionMode="Single">
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:EmployeeRecord">
<Grid Padding="4,10" ColumnSpacing="14">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="220"/>
</Grid.ColumnDefinitions>
<TextBlock Text="{x:Bind PersonnelNumber}"/>
<TextBlock Grid.Column="1" Text="{x:Bind DisplayName}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="2" Text="{x:Bind FingerprintStatus}"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</Grid>
</TabViewItem>
<TabViewItem Header="Уведомления и сканер" IsClosable="False">
<ScrollViewer>
<StackPanel Padding="4,16,20,24" Spacing="14" MaxWidth="720" HorizontalAlignment="Left">
<TextBlock Text="Сканер" Style="{StaticResource SubtitleTextBlockStyle}"/>
<ComboBox x:Name="ReaderCombo" Header="Используемое устройство" MinWidth="320"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="TelegramEnabledSwitch" Header="Telegram" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="TelegramTokenBox" Header="Токен Telegram-бота" PasswordRevealMode="Peek"/>
<TextBox x:Name="TelegramChatBox" Header="Chat ID Telegram"/>
<Border Height="1" Background="{ThemeResource DividerStrokeColorDefaultBrush}"/>
<ToggleSwitch x:Name="MaxEnabledSwitch" Header="MAX" OffContent="Выключен" OnContent="Включён"/>
<PasswordBox x:Name="MaxTokenBox" Header="Токен MAX-бота" PasswordRevealMode="Peek"/>
<TextBox x:Name="MaxChatBox" Header="Chat ID MAX"/>
<StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="Сохранить настройки" Click="SaveSettings_Click"/>
<Button Content="Отправить тест" Click="TestNotification_Click"/>
</StackPanel>
<TextBlock Text="Токены, сотрудники и шаблоны отпечатков хранятся в зашифрованном хранилище текущей учётной записи Windows." TextWrapping="Wrap" Opacity="0.7"/>
</StackPanel>
</ScrollViewer>
</TabViewItem>
</TabView>
</Grid>
</Page>

View file

@ -0,0 +1,233 @@
using KronaStaffTerminal.Models;
using KronaStaffTerminal.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace KronaStaffTerminal;
public sealed partial class MainPage : Page
{
private readonly TerminalStore _store = new();
private readonly NotificationService _notifications = new();
private readonly FingerprintScanner _scanner = new();
private TerminalState _state = new();
private bool _loaded;
public MainPage()
{
InitializeComponent();
_scanner.StatusChanged += message => Enqueue(() => SetStatus(message));
_scanner.RegistrationProgress += count => Enqueue(() => SetStatus($"Регистрация отпечатка: {count}/3", InfoBarSeverity.Informational));
_scanner.RegistrationCompleted += (id, template) => Enqueue(() => _ = CompleteRegistrationAsync(id, template));
_scanner.FingerRecognized += (id, score) => Enqueue(() => _ = RecordArrivalAsync(id, score));
}
private async void Page_Loaded(object sender, RoutedEventArgs e)
{
if (_loaded) return;
_loaded = true;
_state = await _store.LoadAsync();
LoadSettings();
RefreshLists();
ConnectScanner();
}
private void Page_Unloaded(object sender, RoutedEventArgs e)
{
_scanner.Dispose();
}
private void ConnectScanner()
{
try
{
_scanner.Connect(_state.Settings.ReaderIndex, _state.Employees
.Where(employee => employee.HasFingerprint)
.Select(employee => (employee.FingerprintId, employee.FingerprintTemplate!)));
ReaderCombo.Items.Clear();
for (var index = 0; index < _scanner.DeviceCount; index++)
{
ReaderCombo.Items.Add($"SLK20R №{index + 1}");
}
ReaderCombo.SelectedIndex = _scanner.ReaderIndex;
SetStatus($"Сканер SLK20R №{_scanner.ReaderIndex + 1} подключён. Зарегистрировано сотрудников: {_state.Employees.Count(employee => employee.HasFingerprint)}.", InfoBarSeverity.Success);
}
catch (Exception exception)
{
SetStatus(exception.Message, InfoBarSeverity.Error);
}
}
private async void AddEmployee_Click(object sender, RoutedEventArgs e)
{
var name = EmployeeNameBox.Text.Trim();
var number = PersonnelNumberBox.Text.Trim();
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(number))
{
SetStatus("Заполните имя и табельный номер сотрудника.", InfoBarSeverity.Warning);
return;
}
if (_state.Employees.Any(employee => employee.PersonnelNumber.Equals(number, StringComparison.OrdinalIgnoreCase)))
{
SetStatus("Сотрудник с таким табельным номером уже есть.", InfoBarSeverity.Warning);
return;
}
var employee = new EmployeeRecord { DisplayName = name, PersonnelNumber = number };
_state.Employees.Add(employee);
await _store.SaveAsync(_state);
EmployeeNameBox.Text = "";
PersonnelNumberBox.Text = "";
RefreshLists();
EmployeesList.SelectedItem = employee;
SetStatus($"{name} добавлен. Теперь зарегистрируйте отпечаток.", InfoBarSeverity.Success);
}
private void RegisterFingerprint_Click(object sender, RoutedEventArgs e)
{
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
if (!_scanner.IsConnected)
{
SetStatus("Сканер не подключён.", InfoBarSeverity.Error);
return;
}
if (employee.FingerprintId <= 0)
{
employee.FingerprintId = _state.Employees.Select(item => item.FingerprintId).DefaultIfEmpty(0).Max() + 1;
}
_scanner.BeginRegistration(employee.FingerprintId);
}
private async Task CompleteRegistrationAsync(int fingerprintId, string template)
{
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
if (employee is null) return;
employee.FingerprintTemplate = template;
await _store.SaveAsync(_state);
RefreshLists();
TerminalMessage.Text = $"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован";
SetStatus($"Отпечаток сотрудника «{employee.DisplayName}» зарегистрирован.", InfoBarSeverity.Success);
}
private async void DeleteEmployee_Click(object sender, RoutedEventArgs e)
{
if (EmployeesList.SelectedItem is not EmployeeRecord employee)
{
SetStatus("Сначала выберите сотрудника.", InfoBarSeverity.Warning);
return;
}
var dialog = new ContentDialog
{
XamlRoot = XamlRoot,
Title = "Удалить сотрудника?",
Content = $"{employee.DisplayName} и зарегистрированный отпечаток будут удалены из терминала.",
PrimaryButtonText = "Удалить",
CloseButtonText = "Отмена",
DefaultButton = ContentDialogButton.Close
};
if (await dialog.ShowAsync() != ContentDialogResult.Primary) return;
_state.Employees.Remove(employee);
await _store.SaveAsync(_state);
RefreshLists();
ConnectScanner();
SetStatus("Сотрудник удалён.", InfoBarSeverity.Success);
}
private async Task RecordArrivalAsync(int fingerprintId, int score)
{
var employee = _state.Employees.FirstOrDefault(item => item.FingerprintId == fingerprintId);
if (employee is null) return;
var now = DateTimeOffset.UtcNow;
var alreadyRecorded = _state.Attendance.Any(item =>
item.EmployeeId == employee.Id && item.OccurredAtUtc.ToLocalTime().Date == now.ToLocalTime().Date);
if (alreadyRecorded)
{
TerminalMessage.Text = $"{employee.DisplayName}: приход сегодня уже отмечен";
SetStatus($"Повторное сканирование распознано, новая отметка не создана. Точность: {score}.", InfoBarSeverity.Informational);
return;
}
var record = new AttendanceRecord
{
EmployeeId = employee.Id,
EmployeeName = employee.DisplayName,
OccurredAtUtc = now,
ReaderIndex = _scanner.ReaderIndex
};
_state.Attendance.Insert(0, record);
await _store.SaveAsync(_state);
RefreshLists();
TerminalMessage.Text = $"Здравствуйте, {employee.DisplayName}!\nПриход отмечен в {now.ToLocalTime():HH:mm}";
SetStatus($"Приход отмечен. Точность распознавания: {score}.", InfoBarSeverity.Success);
var delivery = await _notifications.SendArrivalAsync(_state.Settings, employee.DisplayName, now.ToLocalTime());
record.NotificationDelivered = delivery.Delivered;
record.NotificationError = delivery.Error;
await _store.SaveAsync(_state);
RefreshLists();
if (delivery.Error is not null)
{
SetStatus($"Приход сохранён, но уведомление не отправлено: {delivery.Error}", InfoBarSeverity.Warning);
}
}
private async void SaveSettings_Click(object sender, RoutedEventArgs e)
{
CopySettingsFromControls();
await _store.SaveAsync(_state);
ConnectScanner();
SetStatus("Настройки сохранены.", InfoBarSeverity.Success);
}
private async void TestNotification_Click(object sender, RoutedEventArgs e)
{
CopySettingsFromControls();
var result = await _notifications.SendTestAsync(_state.Settings);
SetStatus(result.Delivered ? "Тестовое сообщение отправлено." : result.Error ?? "Включите Telegram или MAX и заполните реквизиты.", result.Delivered ? InfoBarSeverity.Success : InfoBarSeverity.Warning);
}
private void CopySettingsFromControls()
{
_state.Settings.ReaderIndex = Math.Max(0, ReaderCombo.SelectedIndex);
_state.Settings.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()
{
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 RefreshLists()
{
EmployeesList.ItemsSource = null;
EmployeesList.ItemsSource = _state.Employees.OrderBy(employee => employee.DisplayName).ToList();
AttendanceList.ItemsSource = null;
AttendanceList.ItemsSource = _state.Attendance.OrderByDescending(item => item.OccurredAtUtc).Take(100).ToList();
}
private void SetStatus(string message, InfoBarSeverity severity = InfoBarSeverity.Informational)
{
StatusBar.Message = message;
StatusBar.Severity = severity;
StatusBar.IsOpen = true;
}
private static void Enqueue(Action action) => App.DispatcherQueue.TryEnqueue(() => action());
}

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="KronaStaffTerminal.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:KronaStaffTerminal"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="Krona Staff — терминал"
mc:Ignorable="d">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TitleBar x:Name="AppTitleBar" Title="Krona Staff" Subtitle="Учёт прихода сотрудников">
<TitleBar.IconSource>
<ImageIconSource ImageSource="Assets/AppIcon.ico" />
</TitleBar.IconSource>
</TitleBar>
<!--
The Frame hosts pages for your application content. Add your UI to
MainPage.xaml rather than here so you can use Page features such as
navigation events and the Loaded lifecycle.
-->
<Frame x:Name="RootFrame" Grid.Row="1" />
</Grid>
</Window>

View file

@ -0,0 +1,28 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace KronaStaffTerminal;
/// <summary>
/// The application window. This hosts a Frame that displays pages. Add your
/// UI and logic to MainPage.xaml / MainPage.xaml.cs instead of here so you
/// can use Page features such as navigation events and the Loaded lifecycle.
/// </summary>
public sealed partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
AppWindow.SetIcon("Assets/AppIcon.ico");
AppWindow.Resize(new Windows.Graphics.SizeInt32(1120, 760));
// Navigate the root frame to the main page on startup.
RootFrame.Navigate(typeof(MainPage));
}
}

View file

@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace KronaStaffTerminal.Models;
public sealed class EmployeeRecord
{
public string Id { get; set; } = UlidGenerator.New();
public string PersonnelNumber { get; set; } = "";
public string DisplayName { get; set; } = "";
public int FingerprintId { get; set; }
public string? FingerprintTemplate { get; set; }
[JsonIgnore]
public bool HasFingerprint => FingerprintId > 0 && !string.IsNullOrWhiteSpace(FingerprintTemplate);
[JsonIgnore]
public string FingerprintStatus => HasFingerprint ? "Отпечаток зарегистрирован" : "Отпечатка нет";
}
public sealed class AttendanceRecord
{
public string Id { get; set; } = UlidGenerator.New();
public string EmployeeId { get; set; } = "";
public string EmployeeName { get; set; } = "";
public DateTimeOffset OccurredAtUtc { get; set; }
public int ReaderIndex { get; set; }
public bool NotificationDelivered { get; set; }
public string? NotificationError { get; set; }
[JsonIgnore]
public string LocalTime => OccurredAtUtc.ToLocalTime().ToString("dd.MM.yyyy HH:mm:ss");
[JsonIgnore]
public string DeliveryStatus => NotificationDelivered ? "Отправлено" : string.IsNullOrWhiteSpace(NotificationError) ? "Не настроено" : "Ошибка отправки";
}
public sealed class TerminalSettings
{
public int ReaderIndex { get; set; }
public bool TelegramEnabled { get; set; }
public string TelegramBotToken { get; set; } = "";
public string TelegramChatId { get; set; } = "";
public bool MaxEnabled { get; set; }
public string MaxBotToken { get; set; } = "";
public string MaxChatId { get; set; } = "";
}
public sealed class TerminalState
{
public List<EmployeeRecord> Employees { get; set; } = [];
public List<AttendanceRecord> Attendance { get; set; } = [];
public TerminalSettings Settings { get; set; } = new();
}

View file

@ -0,0 +1,31 @@
using System.Numerics;
using System.Security.Cryptography;
namespace KronaStaffTerminal.Models;
internal static class UlidGenerator
{
private const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
public static string New()
{
Span<byte> value = stackalloc byte[16];
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (var index = 5; index >= 0; index--)
{
value[index] = (byte)(timestamp & 0xff);
timestamp >>= 8;
}
RandomNumberGenerator.Fill(value[6..]);
var number = new BigInteger(value, isUnsigned: true, isBigEndian: true);
Span<char> result = stackalloc char[26];
for (var index = 25; index >= 0; index--)
{
result[index] = Alphabet[(int)(number & 31)];
number >>= 5;
}
return new string(result);
}
}

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10"
IgnorableNamespaces="uap rescap systemai">
<Identity
Name="223A9635-262B-4298-9F19-1D2FE0A6B5F7"
Publisher="CN=AppPublisher"
Version="1.0.0.0" />
<mp:PhoneIdentity PhoneProductId="223A9635-262B-4298-9F19-1D2FE0A6B5F7" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>KronaStaffTerminal</DisplayName>
<PublisherDisplayName>AppPublisher</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26226.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate"/>
</Resources>
<Applications>
<Application Id="App"
Executable="$targetnametoken$.exe"
EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="KronaStaffTerminal"
Description="KronaStaffTerminal"
BackgroundColor="transparent"
Square150x150Logo="Assets\Square150x150Logo.png"
Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
</Capabilities>
</Package>

View file

@ -0,0 +1,10 @@
{
"profiles": {
"KronaStaffTerminal (Package)": {
"commandName": "MsixPackage"
},
"KronaStaffTerminal (Unpackaged)": {
"commandName": "Project"
}
}
}

View file

@ -0,0 +1,36 @@
# Krona Staff Terminal
Локальный терминал учёта прихода для USB-сканеров ZKTeco/BioLink SLK20R и ZKFinger SDK 5.3.
## Возможности
- выбор одного из подключённых сканеров;
- добавление и удаление сотрудников;
- регистрация отпечатка по трём последовательным сканам;
- распознавание сотрудника и одна отметка прихода в день;
- локальный журнал последних отметок;
- уведомления через Telegram Bot API и MAX Bot API;
- шифрование сотрудников, токенов и шаблонов отпечатков средствами Windows DPAPI.
Исходное изображение пальца не сохраняется. Шаблон отпечатка хранится только локально и может быть расшифрован текущей учётной записью Windows.
## Запуск
Требуются Windows 10/11 x64, драйвер SLK20R и ZKFinger SDK 5.3. Сборка и запуск из корня репозитория:
```powershell
dotnet run --project apps/KronaStaffTerminal/KronaStaffTerminal.csproj -c Debug -p:Platform=x64 --launch-profile "KronaStaffTerminal (Unpackaged)"
```
## Первый сотрудник
1. Откройте вкладку «Сотрудники».
2. Введите табельный номер и имя, затем нажмите «Добавить».
3. Выберите сотрудника в списке и нажмите «Зарегистрировать отпечаток».
4. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Повторный скан в тот же день распознаётся, но вторую отметку прихода не создаёт.
## Уведомления
На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.

View file

@ -0,0 +1,196 @@
using libzkfpcsharp;
namespace KronaStaffTerminal.Services;
public sealed class FingerprintScanner : IDisposable
{
private const int TemplateSize = 2048;
private readonly object _gate = new();
private readonly byte[][] _registrationSamples = [new byte[TemplateSize], new byte[TemplateSize], new byte[TemplateSize]];
private IntPtr _device = IntPtr.Zero;
private IntPtr _database = IntPtr.Zero;
private CancellationTokenSource? _captureCancellation;
private int _registrationCount;
private int? _registrationFingerprintId;
private DateTimeOffset _lastCapture = DateTimeOffset.MinValue;
public event Action<int, int>? FingerRecognized;
public event Action<int>? RegistrationProgress;
public event Action<int, string>? RegistrationCompleted;
public event Action<string>? StatusChanged;
public int DeviceCount { get; private set; }
public int ReaderIndex { get; private set; }
public bool IsConnected => _device != IntPtr.Zero;
public void Connect(int readerIndex, IEnumerable<(int FingerprintId, string Template)> templates)
{
Disconnect();
var result = zkfp2.Init();
if (result != 0)
{
throw new InvalidOperationException($"SDK ZKTeco не инициализировано (код {result}).");
}
DeviceCount = zkfp2.GetDeviceCount();
if (DeviceCount <= 0)
{
zkfp2.Terminate();
throw new InvalidOperationException("Подключённые сканеры не найдены.");
}
ReaderIndex = Math.Clamp(readerIndex, 0, DeviceCount - 1);
_device = zkfp2.OpenDevice(ReaderIndex);
if (_device == IntPtr.Zero)
{
zkfp2.Terminate();
throw new InvalidOperationException($"Не удалось открыть сканер №{ReaderIndex + 1}.");
}
_database = zkfp2.DBInit();
if (_database == IntPtr.Zero)
{
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
zkfp2.Terminate();
throw new InvalidOperationException("Не удалось создать базу распознавания.");
}
foreach (var item in templates)
{
var template = zkfp2.Base64ToBlob(item.Template);
zkfp2.DBAdd(_database, item.FingerprintId, template);
}
_captureCancellation = new CancellationTokenSource();
_ = Task.Run(() => CaptureLoop(_captureCancellation.Token));
StatusChanged?.Invoke($"Сканер №{ReaderIndex + 1} подключён. Приложите палец.");
}
public void BeginRegistration(int fingerprintId)
{
lock (_gate)
{
_registrationFingerprintId = fingerprintId;
_registrationCount = 0;
}
StatusChanged?.Invoke("Регистрация: приложите один и тот же палец 3 раза, каждый раз убирая его со стекла.");
}
public void CancelRegistration()
{
lock (_gate)
{
_registrationFingerprintId = null;
_registrationCount = 0;
}
}
private void CaptureLoop(CancellationToken cancellationToken)
{
var parameter = new byte[4];
var size = 4;
var width = 0;
var height = 0;
zkfp2.GetParameters(_device, 1, parameter, ref size);
zkfp2.ByteArray2Int(parameter, ref width);
size = 4;
zkfp2.GetParameters(_device, 2, parameter, ref size);
zkfp2.ByteArray2Int(parameter, ref height);
var image = new byte[Math.Max(1, width * height)];
var captured = new byte[TemplateSize];
while (!cancellationToken.IsCancellationRequested)
{
var capturedLength = TemplateSize;
if (zkfp2.AcquireFingerprint(_device, image, captured, ref capturedLength) == 0)
{
if (DateTimeOffset.UtcNow - _lastCapture > TimeSpan.FromMilliseconds(900))
{
_lastCapture = DateTimeOffset.UtcNow;
ProcessCapture(captured, capturedLength);
}
}
Thread.Sleep(150);
}
}
private void ProcessCapture(byte[] captured, int capturedLength)
{
lock (_gate)
{
if (_registrationFingerprintId is int fingerprintId)
{
if (_registrationCount > 0 && zkfp2.DBMatch(_database, captured, _registrationSamples[_registrationCount - 1]) <= 0)
{
StatusChanged?.Invoke("Это другой палец. Приложите тот же палец ещё раз.");
return;
}
Array.Clear(_registrationSamples[_registrationCount]);
Array.Copy(captured, _registrationSamples[_registrationCount], capturedLength);
_registrationCount++;
RegistrationProgress?.Invoke(_registrationCount);
if (_registrationCount < 3)
{
StatusChanged?.Invoke($"Скан принято: {_registrationCount}/3. Уберите палец и приложите снова.");
return;
}
var merged = new byte[TemplateSize];
var mergedLength = TemplateSize;
var result = zkfp2.DBMerge(_database, _registrationSamples[0], _registrationSamples[1], _registrationSamples[2], merged, ref mergedLength);
if (result == 0)
{
zkfp2.DBDel(_database, fingerprintId);
result = zkfp2.DBAdd(_database, fingerprintId, merged);
}
_registrationFingerprintId = null;
_registrationCount = 0;
if (result == 0)
{
RegistrationCompleted?.Invoke(fingerprintId, zkfp2.BlobToBase64(merged, mergedLength));
StatusChanged?.Invoke("Отпечаток успешно зарегистрирован.");
}
else
{
StatusChanged?.Invoke($"Не удалось сохранить отпечаток (код {result}). Повторите регистрацию.");
}
return;
}
var foundId = 0;
var score = 0;
if (zkfp2.DBIdentify(_database, captured, ref foundId, ref score) == 0)
{
FingerRecognized?.Invoke(foundId, score);
}
else
{
StatusChanged?.Invoke("Отпечаток не распознан.");
}
}
}
public void Disconnect()
{
_captureCancellation?.Cancel();
_captureCancellation = null;
Thread.Sleep(200);
if (_database != IntPtr.Zero)
{
zkfp2.DBFree(_database);
_database = IntPtr.Zero;
}
if (_device != IntPtr.Zero)
{
zkfp2.CloseDevice(_device);
_device = IntPtr.Zero;
}
zkfp2.Terminate();
}
public void Dispose() => Disconnect();
}

View file

@ -0,0 +1,79 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
public sealed class NotificationService
{
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(15) };
public async Task<(bool Delivered, string? Error)> SendArrivalAsync(TerminalSettings settings, string employeeName, DateTimeOffset localTime)
{
return await SendAsync(settings, $"{employeeName} пришёл(а) в {localTime:HH:mm}");
}
public async Task<(bool Delivered, string? Error)> SendTestAsync(TerminalSettings settings)
{
return await SendAsync(settings, "Krona Staff: тестовое сообщение доставлено.");
}
private async Task<(bool Delivered, string? Error)> SendAsync(TerminalSettings settings, string text)
{
var errors = new List<string>();
var sent = false;
if (settings.TelegramEnabled)
{
try
{
var url = $"https://api.telegram.org/bot{settings.TelegramBotToken}/sendMessage";
using var response = await _httpClient.PostAsJsonAsync(url, new { chat_id = settings.TelegramChatId, text });
if (!response.IsSuccessStatusCode)
{
errors.Add($"Telegram: HTTP {(int)response.StatusCode}");
}
else
{
sent = true;
}
}
catch (Exception exception)
{
errors.Add($"Telegram: {exception.Message}");
}
}
if (settings.MaxEnabled)
{
try
{
var url = $"https://platform-api.max.ru/messages?chat_id={Uri.EscapeDataString(settings.MaxChatId)}";
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.TryAddWithoutValidation("Authorization", settings.MaxBotToken);
request.Content = new StringContent(JsonSerializer.Serialize(new { text }), Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
errors.Add($"MAX: HTTP {(int)response.StatusCode}");
}
else
{
sent = true;
}
}
catch (Exception exception)
{
errors.Add($"MAX: {exception.Message}");
}
}
if (!settings.TelegramEnabled && !settings.MaxEnabled)
{
return (false, null);
}
return (sent && errors.Count == 0, errors.Count == 0 ? null : string.Join("; ", errors));
}
}

View file

@ -0,0 +1,42 @@
using System.Security.Cryptography;
using System.Text.Json;
using KronaStaffTerminal.Models;
namespace KronaStaffTerminal.Services;
public sealed class TerminalStore
{
private static readonly byte[] Entropy = "Krona.Staff.Terminal.v1"u8.ToArray();
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly string _filePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Krona",
"StaffTerminal",
"terminal-state.dat");
public async Task<TerminalState> LoadAsync()
{
try
{
if (!File.Exists(_filePath)) return new TerminalState();
var encrypted = await File.ReadAllBytesAsync(_filePath);
var clear = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
return JsonSerializer.Deserialize<TerminalState>(clear, JsonOptions) ?? new TerminalState();
}
catch
{
return new TerminalState();
}
}
public async Task SaveAsync(TerminalState state)
{
var directory = Path.GetDirectoryName(_filePath)!;
Directory.CreateDirectory(directory);
var clear = JsonSerializer.SerializeToUtf8Bytes(state, JsonOptions);
var encrypted = ProtectedData.Protect(clear, Entropy, DataProtectionScope.CurrentUser);
var temporaryPath = _filePath + ".tmp";
await File.WriteAllBytesAsync(temporaryPath, encrypted);
File.Move(temporaryPath, _filePath, true);
}
}

Binary file not shown.

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="KronaStaffTerminal.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- The ID below informs the system that this application is compatible with OS features first introduced in Windows 10.
It is necessary to support features in unpackaged applications, for example the custom titlebar implementation.
For more info see https://docs.microsoft.com/windows/apps/windows-app-sdk/use-windows-app-sdk-run-time#declare-os-compatibility-in-your-application-manifest -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>