|
|
@ -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
|
|
@ -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
|
||||
16
apps/KronaStaffTerminal/App.xaml
Normal 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>
|
||||
57
apps/KronaStaffTerminal/App.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
BIN
apps/KronaStaffTerminal/Assets/AppIcon.ico
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
apps/KronaStaffTerminal/Assets/LockScreenLogo.scale-200.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/KronaStaffTerminal/Assets/SplashScreen.scale-200.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/KronaStaffTerminal/Assets/Square150x150Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/KronaStaffTerminal/Assets/Square44x44Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/KronaStaffTerminal/Assets/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/KronaStaffTerminal/Assets/Wide310x150Logo.scale-200.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
82
apps/KronaStaffTerminal/KronaStaffTerminal.csproj
Normal 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>
|
||||
130
apps/KronaStaffTerminal/MainPage.xaml
Normal 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="" 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>
|
||||
233
apps/KronaStaffTerminal/MainPage.xaml.cs
Normal 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());
|
||||
}
|
||||
34
apps/KronaStaffTerminal/MainWindow.xaml
Normal 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>
|
||||
28
apps/KronaStaffTerminal/MainWindow.xaml.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
53
apps/KronaStaffTerminal/Models/TerminalModels.cs
Normal 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();
|
||||
}
|
||||
31
apps/KronaStaffTerminal/Models/UlidGenerator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
53
apps/KronaStaffTerminal/Package.appxmanifest
Normal 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>
|
||||
10
apps/KronaStaffTerminal/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"profiles": {
|
||||
"KronaStaffTerminal (Package)": {
|
||||
"commandName": "MsixPackage"
|
||||
},
|
||||
"KronaStaffTerminal (Unpackaged)": {
|
||||
"commandName": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/KronaStaffTerminal/README.md
Normal 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. Трижды приложите один и тот же палец, убирая его со стекла после каждого принятого скана.
|
||||
|
||||
После регистрации вкладка «Терминал» постоянно ожидает отпечаток. Повторный скан в тот же день распознаётся, но вторую отметку прихода не создаёт.
|
||||
|
||||
## Уведомления
|
||||
|
||||
На вкладке «Уведомления и сканер» включите нужный канал, укажите токен бота и идентификатор чата, сохраните настройки и нажмите «Отправить тест». При успешном распознавании сообщение содержит имя сотрудника и местное время прихода.
|
||||
196
apps/KronaStaffTerminal/Services/FingerprintScanner.cs
Normal 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();
|
||||
}
|
||||
79
apps/KronaStaffTerminal/Services/NotificationService.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
42
apps/KronaStaffTerminal/Services/TerminalStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
BIN
apps/KronaStaffTerminal/Vendor/ZKFinger/x64/libzkfpcsharp.dll
vendored
Normal file
19
apps/KronaStaffTerminal/app.manifest
Normal 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>
|
||||