|
|
@ -23,3 +23,7 @@ docker compose up --build app postgres redis
|
||||||
- `npm run check:contracts` — проверить manifest/settings/OpenAPI.
|
- `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).
|
Архитектурные решения: [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>
|
||||||