������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������) #1
6
Elfisa.Cross.slnx
Normal file
6
Elfisa.Cross.slnx
Normal file
@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Elfisa.Avalonia/Elfisa.Avalonia.csproj" />
|
||||
<Project Path="src/Elfisa.Core/Elfisa.Core.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
52
src/Elfisa.Avalonia/App.axaml
Normal file
52
src/Elfisa.Avalonia/App.axaml
Normal file
@ -0,0 +1,52 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="Elfisa.Avalonia.App"
|
||||
xmlns:local="using:Elfisa.Avalonia"
|
||||
RequestedThemeVariant="Light">
|
||||
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<SolidColorBrush x:Key="Bg" Color="#EDF2F2"/>
|
||||
<SolidColorBrush x:Key="Surface" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="Text" Color="#1F2933"/>
|
||||
<SolidColorBrush x:Key="Muted" Color="#6B7785"/>
|
||||
<SolidColorBrush x:Key="Border" Color="#E1E7E8"/>
|
||||
<SolidColorBrush x:Key="RowAlt" Color="#F5F9F9"/>
|
||||
<SolidColorBrush x:Key="InputBg" Color="#FFFFFF"/>
|
||||
</ResourceDictionary>
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="Bg" Color="#0E1416"/>
|
||||
<SolidColorBrush x:Key="Surface" Color="#182226"/>
|
||||
<SolidColorBrush x:Key="Text" Color="#E7EDEE"/>
|
||||
<SolidColorBrush x:Key="Muted" Color="#8FA0A5"/>
|
||||
<SolidColorBrush x:Key="Border" Color="#28353A"/>
|
||||
<SolidColorBrush x:Key="RowAlt" Color="#141D20"/>
|
||||
<SolidColorBrush x:Key="InputBg" Color="#0F1719"/>
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- Акцент бренда (#0F766E — бирюза шапки), постоянен в обеих темах -->
|
||||
<Color x:Key="AccentColor">#0F766E</Color>
|
||||
<SolidColorBrush x:Key="Accent" Color="#0F766E"/>
|
||||
<SolidColorBrush x:Key="AccentHover" Color="#0D9488"/>
|
||||
<SolidColorBrush x:Key="AccentPressed" Color="#115E59"/>
|
||||
<SolidColorBrush x:Key="AccentSoft" Color="#CCFBF1"/>
|
||||
<SolidColorBrush x:Key="Sidebar" Color="#0E5F58"/>
|
||||
<SolidColorBrush x:Key="SidebarActive" Color="#0A4A44"/>
|
||||
<SolidColorBrush x:Key="SidebarText" Color="#D5F5F0"/>
|
||||
<SolidColorBrush x:Key="OnAccent" Color="#FFFFFF"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||
<StyleInclude Source="/Styles/Elfisa.axaml"/>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
31
src/Elfisa.Avalonia/App.axaml.cs
Normal file
31
src/Elfisa.Avalonia/App.axaml.cs
Normal file
@ -0,0 +1,31 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
using Elfisa.Avalonia.Views;
|
||||
|
||||
namespace Elfisa.Avalonia;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new RootViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
BIN
src/Elfisa.Avalonia/Assets/avalonia-logo.ico
Normal file
BIN
src/Elfisa.Avalonia/Assets/avalonia-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
31
src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
Normal file
31
src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
Normal file
@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Models\" />
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Elfisa.Core\Elfisa.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
24
src/Elfisa.Avalonia/Program.cs
Normal file
24
src/Elfisa.Avalonia/Program.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace Elfisa.Avalonia;
|
||||
|
||||
sealed class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
#if DEBUG
|
||||
.WithDeveloperTools()
|
||||
#endif
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
65
src/Elfisa.Avalonia/README.md
Normal file
65
src/Elfisa.Avalonia/README.md
Normal file
@ -0,0 +1,65 @@
|
||||
# ЭльФиСА — кросс-платформенный клиент (Avalonia)
|
||||
|
||||
Порт десктопного клиента с WinForms на **Avalonia UI** (.NET 8) —
|
||||
работает на **Windows, macOS и Linux** из одного кода. Начат как замена
|
||||
WinForms-версии (которая только под Windows).
|
||||
|
||||
## Что уже готово (фундамент + эталонный экран)
|
||||
- **Elfisa.Core** — переносимая бизнес-логика (без UI): `ApiClient`
|
||||
(логин, отчёт покупателя, ИИ-отчёт), DTO, `AppConfig`, `Session`.
|
||||
- **Дизайн-система** в фирменной бирюзе (#0F766E): светлая/тёмная тема,
|
||||
стили кнопок, полей, карточек, таблицы, сайдбара (`Styles/Elfisa.axaml`).
|
||||
- **Экран входа** (`LoginView`) — реальная авторизация через `/auth/login`.
|
||||
- **Шелл** (`ShellView`) — сайдбар-навигация + карточка пользователя + выход.
|
||||
- **Отчёты** (`ReportsView`) — ПОЛНОСТЬЮ рабочий эталонный раздел:
|
||||
период, 4 типа отчёта (агрегация как в WinForms), авто-загрузка,
|
||||
свободный **ИИ-запрос → Word/Excel** (через `/ai`), таблица с динамическими
|
||||
колонками. Данные скоупятся по JWT — только заказы этого покупателя.
|
||||
- Заглушки остальных разделов (Прайс-лист, Заказы, Накладные, Отправить,
|
||||
Отказы, Справочник).
|
||||
|
||||
## Архитектура
|
||||
MVVM (CommunityToolkit.Mvvm) + ViewLocator (VM → View по имени).
|
||||
```
|
||||
Program → App → MainWindow(RootViewModel)
|
||||
RootViewModel: LoginViewModel ⇄ ShellViewModel
|
||||
ShellViewModel.CurrentPage: ReportsViewModel | PlaceholderPageViewModel | …
|
||||
```
|
||||
Вся сетевая логика — в `Elfisa.Core` (переиспользуется и десктопом, и
|
||||
будущим мобильным/веб-клиентом Avalonia).
|
||||
|
||||
## Запуск
|
||||
```bash
|
||||
dotnet run --project src/Elfisa.Avalonia
|
||||
```
|
||||
Dev-автологин (удобно при разработке — минуя экран входа):
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:ELF_DEV_USER="user@example.com"; $env:ELF_DEV_PASS="***"; dotnet run --project src/Elfisa.Avalonia
|
||||
```
|
||||
Переопределение адресов (по умолчанию https://24pharmdata.ru и .../ai):
|
||||
`ELF_API_BASE_URL`, `ELF_AI_BASE_URL`.
|
||||
|
||||
Сборка под конкретную ОС:
|
||||
```bash
|
||||
dotnet publish src/Elfisa.Avalonia -c Release -r osx-arm64 --self-contained # Mac (M-серия)
|
||||
dotnet publish src/Elfisa.Avalonia -c Release -r win-x64 --self-contained # Windows
|
||||
```
|
||||
|
||||
## Дорожная карта портирования
|
||||
Логика этих экранов частично уже есть в WinForms `ApiClient` — переносится
|
||||
в `Elfisa.Core` метод за методом, UI пишется по образцу `ReportsView`.
|
||||
|
||||
| Раздел | Что нужно | Сложность |
|
||||
|---|---|---|
|
||||
| Прайс-лист | поиск/каталог, наценки, добавление в заказ, большой грид | высокая |
|
||||
| Заказы | список + карточка заказа + позиции | средняя |
|
||||
| Накладные | список приходов | средняя |
|
||||
| Отправить | сборка заявки + выгрузка DBF (портировать `orderexport`) | средняя |
|
||||
| Отказы | список отказных позиций | низкая |
|
||||
| Справочник | контрагенты, грузополучатели, настройки | средняя |
|
||||
| Автообновление | аналог AutoUpdater под каждую ОС | средняя |
|
||||
|
||||
**Порядок рекомендую:** Заказы → Прайс-лист → Отправить → остальное.
|
||||
Бизнес-логика каждого — обычный C#, переносится как есть; переписывается
|
||||
только UI (WinForms → Avalonia XAML), что и есть основная работа.
|
||||
146
src/Elfisa.Avalonia/Styles/Elfisa.axaml
Normal file
146
src/Elfisa.Avalonia/Styles/Elfisa.axaml
Normal file
@ -0,0 +1,146 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============ Типографика ============ -->
|
||||
<Style Selector="TextBlock.h1">
|
||||
<Setter Property="FontSize" Value="22"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.h2">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Muted}"/>
|
||||
<Setter Property="FontSize" Value="12.5"/>
|
||||
</Style>
|
||||
<Style Selector="TextBlock.label">
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============ Карточка ============ -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="12"/>
|
||||
<Setter Property="Padding" Value="18"/>
|
||||
<Setter Property="BoxShadow" Value="0 2 14 0 #12000000"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============ Кнопки ============ -->
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="{DynamicResource Accent}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
|
||||
<Setter Property="Padding" Value="18,10"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="FontSize" Value="13.5"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
<Style Selector="Button.primary:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentHover}"/>
|
||||
<Setter Property="TextBlock.Foreground" Value="{DynamicResource OnAccent}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.primary:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentPressed}"/>
|
||||
</Style>
|
||||
<Style Selector="Button.primary:disabled /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource Muted}"/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Accent}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="16,9"/>
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentSoft}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============ Поля ввода ============ -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||
<Setter Property="MinHeight" Value="38"/>
|
||||
</Style>
|
||||
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Accent}"/>
|
||||
</Style>
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="MinHeight" Value="38"/>
|
||||
<Setter Property="Padding" Value="10,6"/>
|
||||
<Setter Property="Background" Value="{DynamicResource InputBg}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource Border}"/>
|
||||
</Style>
|
||||
<Style Selector="DatePicker">
|
||||
<Setter Property="CornerRadius" Value="8"/>
|
||||
<Setter Property="MinHeight" Value="38"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============ Сайдбар-навигация ============ -->
|
||||
<Style Selector="ListBox.nav">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="ListBox.nav ListBoxItem">
|
||||
<Setter Property="Foreground" Value="{DynamicResource SidebarText}"/>
|
||||
<Setter Property="Padding" Value="14,11"/>
|
||||
<Setter Property="Margin" Value="10,2"/>
|
||||
<Setter Property="CornerRadius" Value="9"/>
|
||||
<Setter Property="FontSize" Value="14"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
</Style>
|
||||
<Style Selector="ListBox.nav ListBoxItem:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBox.nav ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource SidebarActive}"/>
|
||||
</Style>
|
||||
<Style Selector="ListBox.nav ListBoxItem:selected">
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============ Таблица ============ -->
|
||||
<Style Selector="DataGrid">
|
||||
<Setter Property="Background" Value="{DynamicResource Surface}"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource Border}"/>
|
||||
<Setter Property="RowBackground" Value="{DynamicResource Surface}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource Text}"/>
|
||||
<Setter Property="RowHeight" Value="34"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
<Style Selector="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="{DynamicResource Accent}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource OnAccent}"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
<Setter Property="Padding" Value="10,8"/>
|
||||
<Setter Property="MinHeight" Value="36"/>
|
||||
<Setter Property="SeparatorBrush" Value="#2FFFFFFF"/>
|
||||
</Style>
|
||||
<Style Selector="DataGridCell">
|
||||
<Setter Property="Padding" Value="10,0"/>
|
||||
</Style>
|
||||
<Style Selector="DataGridRow:selected /template/ Rectangle#BackgroundRectangle">
|
||||
<Setter Property="Fill" Value="{DynamicResource AccentSoft}"/>
|
||||
<Setter Property="Opacity" Value="1"/>
|
||||
</Style>
|
||||
</Styles>
|
||||
37
src/Elfisa.Avalonia/ViewLocator.cs
Normal file
37
src/Elfisa.Avalonia/ViewLocator.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
|
||||
namespace Elfisa.Avalonia;
|
||||
|
||||
/// <summary>
|
||||
/// Given a view model, returns the corresponding view if possible.
|
||||
/// </summary>
|
||||
[RequiresUnreferencedCode(
|
||||
"Default implementation of ViewLocator involves reflection which may be trimmed away.",
|
||||
Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")]
|
||||
public class ViewLocator : IDataTemplate
|
||||
{
|
||||
public Control? Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
return null;
|
||||
|
||||
var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null)
|
||||
{
|
||||
return (Control)Activator.CreateInstance(type)!;
|
||||
}
|
||||
|
||||
return new TextBlock { Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data)
|
||||
{
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
||||
44
src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs
Normal file
44
src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
public partial class LoginViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Action _onSuccess;
|
||||
private readonly ApiClient _api = new();
|
||||
|
||||
[ObservableProperty] private string _username = "";
|
||||
[ObservableProperty] private string _password = "";
|
||||
[ObservableProperty] private string? _error;
|
||||
[ObservableProperty] private bool _busy;
|
||||
|
||||
public LoginViewModel(Action onSuccess) => _onSuccess = onSuccess;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoginAsync()
|
||||
{
|
||||
Error = null;
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
Error = "Введите логин и пароль.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Busy = true;
|
||||
var resp = await _api.LoginAsync(Username.Trim(), Password);
|
||||
Session.Current.Token = resp.Token;
|
||||
Session.Current.Role = resp.Role;
|
||||
Session.Current.Username = Username.Trim();
|
||||
_onSuccess();
|
||||
}
|
||||
catch (ApiException ex) { Error = ex.Message; }
|
||||
catch (Exception ex) { Error = "Не удалось войти: " + ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
}
|
||||
15
src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs
Normal file
15
src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs
Normal file
@ -0,0 +1,15 @@
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>Заглушка ещё не портированного раздела.</summary>
|
||||
public partial class PlaceholderPageViewModel : ViewModelBase
|
||||
{
|
||||
public string Title { get; }
|
||||
public string Description { get; }
|
||||
public string Note { get; } = "Раздел портируется на Avalonia. Пока доступен в WinForms-клиенте.";
|
||||
|
||||
public PlaceholderPageViewModel(string title, string description)
|
||||
{
|
||||
Title = title;
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
215
src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
Normal file
215
src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs
Normal file
@ -0,0 +1,215 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
public sealed class ReportColumn
|
||||
{
|
||||
public string Header { get; init; } = "";
|
||||
public bool Numeric { get; init; }
|
||||
public double Weight { get; init; } = 100; // ширина в пикселях (для нетянущихся)
|
||||
public bool Stretch { get; init; } // тянуть на всё свободное место (star)
|
||||
}
|
||||
|
||||
/// <summary>Строка отчёта: ячейки как строки, доступ по индексу для DataGrid.</summary>
|
||||
public sealed class ReportRow
|
||||
{
|
||||
private readonly string[] _cells;
|
||||
public ReportRow(string[] cells) => _cells = cells;
|
||||
public string this[int i] => i >= 0 && i < _cells.Length ? _cells[i] : "";
|
||||
}
|
||||
|
||||
public partial class ReportsViewModel : ViewModelBase
|
||||
{
|
||||
private readonly ApiClient _api = new();
|
||||
private List<BuyerReportLineDto> _lines = new();
|
||||
|
||||
/// <summary>Сигнал View пересобрать колонки DataGrid.</summary>
|
||||
public event Action? ColumnsChanged;
|
||||
|
||||
public ObservableCollection<ReportColumn> Columns { get; } = new();
|
||||
public ObservableCollection<ReportRow> Rows { get; } = new();
|
||||
|
||||
[ObservableProperty] private DateTimeOffset _dateFrom = DateTimeOffset.Now.AddMonths(-1);
|
||||
[ObservableProperty] private DateTimeOffset _dateTo = DateTimeOffset.Now;
|
||||
|
||||
public string[] ReportTypes { get; } = { "Мои заказы", "По поставщикам", "По товарам", "По аптекам" };
|
||||
[ObservableProperty] private int _selectedReportIndex;
|
||||
|
||||
public string[] AiFormats { get; } = { "Word", "Excel" };
|
||||
[ObservableProperty] private int _selectedAiFormat;
|
||||
[ObservableProperty] private string _aiPrompt = "";
|
||||
|
||||
[ObservableProperty] private string _totals = "";
|
||||
[ObservableProperty] private string? _status;
|
||||
[ObservableProperty] private bool _busy;
|
||||
[ObservableProperty] private bool _aiBusy;
|
||||
|
||||
public ReportsViewModel()
|
||||
{
|
||||
_api.SetToken(Session.Current.Token);
|
||||
if (Session.Current.IsAuthenticated)
|
||||
_ = BuildAsync(); // авто-загрузка за последний месяц при открытии
|
||||
}
|
||||
|
||||
partial void OnSelectedReportIndexChanged(int value) => Render();
|
||||
|
||||
[RelayCommand]
|
||||
private async Task BuildAsync()
|
||||
{
|
||||
Status = null;
|
||||
try
|
||||
{
|
||||
Busy = true;
|
||||
_lines = await _api.GetBuyerReportAsync(DateFrom.DateTime.Date, DateTo.DateTime.Date);
|
||||
Render();
|
||||
if (_lines.Count == 0) Status = "За выбранный период заказов нет.";
|
||||
}
|
||||
catch (Exception ex) { Status = ex.Message; }
|
||||
finally { Busy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task GenerateAiAsync()
|
||||
{
|
||||
Status = null;
|
||||
if (string.IsNullOrWhiteSpace(AiPrompt)) { Status = "Введите запрос для ИИ."; return; }
|
||||
try
|
||||
{
|
||||
AiBusy = true;
|
||||
var fmt = SelectedAiFormat == 1 ? "xlsx" : "docx";
|
||||
var res = await _api.GenerateAiReportAsync(AiPrompt.Trim(), fmt);
|
||||
var dir = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
var safe = string.Join("_", res.FileName.Split(Path.GetInvalidFileNameChars()));
|
||||
var path = Path.Combine(dir, safe);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(safe);
|
||||
var ext = Path.GetExtension(safe);
|
||||
path = Path.Combine(dir, $"{name}_{DateTime.Now:yyyyMMdd_HHmmss}{ext}");
|
||||
}
|
||||
File.WriteAllBytes(path, res.Content);
|
||||
Status = "Отчёт сохранён: " + path;
|
||||
OpenFile(path);
|
||||
}
|
||||
catch (Exception ex) { Status = ex.Message; }
|
||||
finally { AiBusy = false; }
|
||||
}
|
||||
|
||||
private void Render()
|
||||
{
|
||||
Columns.Clear();
|
||||
Rows.Clear();
|
||||
|
||||
var (cols, rows) = Aggregate(_lines, SelectedReportIndex);
|
||||
foreach (var c in cols) Columns.Add(c);
|
||||
foreach (var r in rows) Rows.Add(new ReportRow(r));
|
||||
|
||||
var orders = _lines.Select(l => l.OrderId).Distinct().Count();
|
||||
var sum = _lines.Sum(l => l.Sum);
|
||||
Totals = $"Заказов: {orders} Позиций: {_lines.Count} Сумма: {sum:N2}";
|
||||
ColumnsChanged?.Invoke();
|
||||
}
|
||||
|
||||
private static (List<ReportColumn> cols, List<string[]> rows) Aggregate(List<BuyerReportLineDto> lines, int type)
|
||||
{
|
||||
string N2(decimal d) => d.ToString("N2");
|
||||
string N3(decimal d) => d.ToString("N3");
|
||||
string D(DateTime? dt) => dt?.ToLocalTime().ToString("dd.MM.yyyy") ?? "";
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case 1: // По поставщикам
|
||||
{
|
||||
var cols = new List<ReportColumn>
|
||||
{
|
||||
new() { Header = "Поставщик", Stretch = true },
|
||||
new() { Header = "Заказов", Numeric = true, Weight = 90 },
|
||||
new() { Header = "Позиций", Numeric = true, Weight = 90 },
|
||||
new() { Header = "Сумма", Numeric = true, Weight = 120 },
|
||||
};
|
||||
var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Supplier) ? "(не указан)" : l.Supplier!)
|
||||
.OrderByDescending(g => g.Sum(x => x.Sum))
|
||||
.Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
|
||||
g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
|
||||
return (cols, rows);
|
||||
}
|
||||
case 2: // По товарам
|
||||
{
|
||||
var cols = new List<ReportColumn>
|
||||
{
|
||||
new() { Header = "Товар", Stretch = true },
|
||||
new() { Header = "Код", Weight = 100 },
|
||||
new() { Header = "Кол-во", Numeric = true, Weight = 100 },
|
||||
new() { Header = "Сумма", Numeric = true, Weight = 120 },
|
||||
};
|
||||
var rows = lines.GroupBy(l => ((l.ItemName ?? "").Trim() + "|" + (l.ItemCode ?? "")))
|
||||
.OrderByDescending(g => g.Sum(x => x.Sum))
|
||||
.Select(g => { var f = g.First(); return new[] { f.ItemName ?? "", f.ItemCode ?? "",
|
||||
N3(g.Sum(x => x.Qty)), N2(g.Sum(x => x.Sum)) }; }).ToList();
|
||||
return (cols, rows);
|
||||
}
|
||||
case 3: // По аптекам
|
||||
{
|
||||
var cols = new List<ReportColumn>
|
||||
{
|
||||
new() { Header = "Аптека", Stretch = true },
|
||||
new() { Header = "Заказов", Numeric = true, Weight = 90 },
|
||||
new() { Header = "Позиций", Numeric = true, Weight = 90 },
|
||||
new() { Header = "Сумма", Numeric = true, Weight = 120 },
|
||||
};
|
||||
var rows = lines.GroupBy(l => string.IsNullOrWhiteSpace(l.Location) ? "(не указана)" : l.Location!)
|
||||
.OrderByDescending(g => g.Sum(x => x.Sum))
|
||||
.Select(g => new[] { g.Key, g.Select(x => x.OrderId).Distinct().Count().ToString(),
|
||||
g.Count().ToString(), N2(g.Sum(x => x.Sum)) }).ToList();
|
||||
return (cols, rows);
|
||||
}
|
||||
default: // Мои заказы
|
||||
{
|
||||
var cols = new List<ReportColumn>
|
||||
{
|
||||
new() { Header = "Дата", Weight = 105 },
|
||||
new() { Header = "Номер", Weight = 138 },
|
||||
new() { Header = "Поставщик", Weight = 150 },
|
||||
new() { Header = "Аптека", Stretch = true },
|
||||
new() { Header = "Позиций", Numeric = true, Weight = 95 },
|
||||
new() { Header = "Сумма", Numeric = true, Weight = 125 },
|
||||
new() { Header = "Статус", Weight = 100 },
|
||||
};
|
||||
var rows = lines.GroupBy(l => l.OrderId)
|
||||
.OrderByDescending(g => g.First().OrderDate ?? DateTime.MinValue)
|
||||
.Select(g =>
|
||||
{
|
||||
var f = g.First();
|
||||
var sup = string.Join(", ", g.Select(x => x.Supplier).Where(s => !string.IsNullOrWhiteSpace(s)).Distinct());
|
||||
return new[] { D(f.OrderDate), f.GlobalSign ?? "", sup, f.Location ?? "",
|
||||
g.Count().ToString(), N2(g.Sum(x => x.Sum)), f.Status ?? "" };
|
||||
}).ToList();
|
||||
return (cols, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void OpenFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
Process.Start(new ProcessStartInfo { FileName = path, UseShellExecute = true });
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
Process.Start("open", $"\"{path}\"");
|
||||
else
|
||||
Process.Start("xdg-open", $"\"{path}\"");
|
||||
}
|
||||
catch { /* открыть не удалось — путь показан в статусе */ }
|
||||
}
|
||||
}
|
||||
48
src/Elfisa.Avalonia/ViewModels/RootViewModel.cs
Normal file
48
src/Elfisa.Avalonia/ViewModels/RootViewModel.cs
Normal file
@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
/// <summary>Корневой VM: переключает экран входа и основной шелл.</summary>
|
||||
public partial class RootViewModel : ViewModelBase
|
||||
{
|
||||
private ViewModelBase _current = null!;
|
||||
public ViewModelBase Current
|
||||
{
|
||||
get => _current;
|
||||
private set => SetProperty(ref _current, value);
|
||||
}
|
||||
|
||||
public RootViewModel()
|
||||
{
|
||||
Current = new LoginViewModel(OnLoggedIn);
|
||||
TryDevAutoLogin();
|
||||
}
|
||||
|
||||
// Dev-режим: если заданы ELF_DEV_USER/ELF_DEV_PASS — входим автоматически.
|
||||
// Обычных пользователей не затрагивает (переменных нет).
|
||||
private async void TryDevAutoLogin()
|
||||
{
|
||||
var u = Environment.GetEnvironmentVariable("ELF_DEV_USER");
|
||||
var p = Environment.GetEnvironmentVariable("ELF_DEV_PASS");
|
||||
if (string.IsNullOrWhiteSpace(u) || string.IsNullOrWhiteSpace(p)) return;
|
||||
try
|
||||
{
|
||||
var api = new ApiClient();
|
||||
var resp = await api.LoginAsync(u, p);
|
||||
Session.Current.Token = resp.Token;
|
||||
Session.Current.Role = resp.Role;
|
||||
Session.Current.Username = u;
|
||||
OnLoggedIn();
|
||||
}
|
||||
catch { /* остаёмся на экране входа */ }
|
||||
}
|
||||
|
||||
private void OnLoggedIn() => Current = new ShellViewModel(Logout);
|
||||
|
||||
private void Logout()
|
||||
{
|
||||
Session.Current.Clear();
|
||||
Current = new LoginViewModel(OnLoggedIn);
|
||||
}
|
||||
}
|
||||
59
src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs
Normal file
59
src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Elfisa.Core;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
public class NavItem
|
||||
{
|
||||
public string Title { get; }
|
||||
public string Icon { get; }
|
||||
public Func<ViewModelBase> Factory { get; }
|
||||
public NavItem(string title, string icon, Func<ViewModelBase> factory)
|
||||
{
|
||||
Title = title; Icon = icon; Factory = factory;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class ShellViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Action _logout;
|
||||
|
||||
public ObservableCollection<NavItem> NavItems { get; }
|
||||
|
||||
[ObservableProperty] private NavItem? _selectedNav;
|
||||
[ObservableProperty] private ViewModelBase? _currentPage;
|
||||
|
||||
public string UserName => Session.Current.Username ?? "";
|
||||
public string RoleLabel => Session.Current.Role switch
|
||||
{
|
||||
"buyer" => "Покупатель",
|
||||
"supplier" => "Поставщик",
|
||||
"manager" => "Менеджер",
|
||||
"admin" => "Администратор",
|
||||
_ => Session.Current.Role ?? ""
|
||||
};
|
||||
|
||||
public ShellViewModel(Action logout)
|
||||
{
|
||||
_logout = logout;
|
||||
NavItems = new ObservableCollection<NavItem>
|
||||
{
|
||||
new("Прайс-лист", "P", () => new PlaceholderPageViewModel("Прайс-лист", "Каталог, поиск и добавление позиций в заказ.")),
|
||||
new("Заказы", "З", () => new PlaceholderPageViewModel("Заказы", "История заказов и их статусы.")),
|
||||
new("Накладные", "Н", () => new PlaceholderPageViewModel("Накладные", "Приход товара по заказам.")),
|
||||
new("Отчёты", "О", () => new ReportsViewModel()),
|
||||
new("Отправить", "→", () => new PlaceholderPageViewModel("Отправить", "Формирование и выгрузка заявок поставщику (DBF).")),
|
||||
new("Отказы", "!", () => new PlaceholderPageViewModel("Отказы", "Отказные позиции по заказам.")),
|
||||
new("Справочник", "С", () => new PlaceholderPageViewModel("Справочник", "Контрагенты, грузополучатели, настройки.")),
|
||||
};
|
||||
SelectedNav = NavItems.First(n => n.Title == "Отчёты");
|
||||
}
|
||||
|
||||
partial void OnSelectedNavChanged(NavItem? value) => CurrentPage = value?.Factory();
|
||||
|
||||
[RelayCommand] private void Logout() => _logout();
|
||||
}
|
||||
7
src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs
Normal file
7
src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs
Normal file
@ -0,0 +1,7 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Elfisa.Avalonia.ViewModels;
|
||||
|
||||
public abstract class ViewModelBase : ObservableObject
|
||||
{
|
||||
}
|
||||
39
src/Elfisa.Avalonia/Views/LoginView.axaml
Normal file
39
src/Elfisa.Avalonia/Views/LoginView.axaml
Normal file
@ -0,0 +1,39 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
x:Class="Elfisa.Avalonia.Views.LoginView"
|
||||
x:DataType="vm:LoginViewModel">
|
||||
|
||||
<Grid Background="{DynamicResource Bg}">
|
||||
<Border Classes="card" Width="390" Padding="30"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="16">
|
||||
<StackPanel Spacing="2" Margin="0,0,0,6">
|
||||
<TextBlock Text="ЭльФиСА" FontSize="28" FontWeight="Bold"
|
||||
Foreground="{DynamicResource Accent}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="Электронная Фармация" Classes="muted" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Логин" Classes="label"/>
|
||||
<TextBox Text="{Binding Username}" Watermark="e-mail или логин"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Пароль" Classes="label"/>
|
||||
<TextBox Text="{Binding Password}" PasswordChar="●" Watermark="пароль"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="#1FDC2626" CornerRadius="6" Padding="10,7"
|
||||
IsVisible="{Binding Error, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Error}" Foreground="#DC2626" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<Button Classes="primary" Content="Войти" IsDefault="True"
|
||||
HorizontalAlignment="Stretch" Margin="0,4,0,0"
|
||||
Command="{Binding LoginCommand}" IsEnabled="{Binding !Busy}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/LoginView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/LoginView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class LoginView : UserControl
|
||||
{
|
||||
public LoginView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
16
src/Elfisa.Avalonia/Views/MainWindow.axaml
Normal file
16
src/Elfisa.Avalonia/Views/MainWindow.axaml
Normal file
@ -0,0 +1,16 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="740"
|
||||
x:Class="Elfisa.Avalonia.Views.MainWindow"
|
||||
x:DataType="vm:RootViewModel"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Width="1200" Height="740" MinWidth="920" MinHeight="600"
|
||||
Background="{DynamicResource Bg}"
|
||||
Title="ЭльФиСА">
|
||||
|
||||
<ContentControl Content="{Binding Current}"/>
|
||||
|
||||
</Window>
|
||||
11
src/Elfisa.Avalonia/Views/MainWindow.axaml.cs
Normal file
11
src/Elfisa.Avalonia/Views/MainWindow.axaml.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
24
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml
Normal file
24
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml
Normal file
@ -0,0 +1,24 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
x:Class="Elfisa.Avalonia.Views.PlaceholderPageView"
|
||||
x:DataType="vm:PlaceholderPageViewModel">
|
||||
|
||||
<Grid Margin="28" RowDefinitions="Auto,*">
|
||||
<StackPanel Grid.Row="0" Spacing="4">
|
||||
<TextBlock Text="{Binding Title}" Classes="h1"/>
|
||||
<TextBlock Text="{Binding Description}" Classes="muted"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border Grid.Row="1" Classes="card" MaxWidth="480" Padding="34"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="14" HorizontalAlignment="Center">
|
||||
<Border Width="56" Height="56" CornerRadius="28" Background="{DynamicResource AccentSoft}">
|
||||
<TextBlock Text="⏳" FontSize="24" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="Скоро" Classes="h2" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Note}" Classes="muted" TextWrapping="Wrap" TextAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/PlaceholderPageView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class PlaceholderPageView : UserControl
|
||||
{
|
||||
public PlaceholderPageView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
58
src/Elfisa.Avalonia/Views/ReportsView.axaml
Normal file
58
src/Elfisa.Avalonia/Views/ReportsView.axaml
Normal file
@ -0,0 +1,58 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
x:Class="Elfisa.Avalonia.Views.ReportsView"
|
||||
x:DataType="vm:ReportsViewModel">
|
||||
|
||||
<DockPanel Margin="24">
|
||||
|
||||
<!-- Заголовок -->
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Text="Отчёты" Classes="h1"/>
|
||||
<TextBlock Text="Данные только по вашим заказам" Classes="muted"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Totals}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
FontWeight="SemiBold" Foreground="{DynamicResource Accent}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Панель управления -->
|
||||
<Border DockPanel.Dock="Top" Classes="card" Margin="0,0,0,12" Padding="16">
|
||||
<StackPanel Spacing="12">
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBlock Text="С" Classes="label" Margin="0,0,8,6"/>
|
||||
<DatePicker SelectedDate="{Binding DateFrom}" Margin="0,0,10,6"/>
|
||||
<TextBlock Text="по" Classes="label" Margin="0,0,8,6"/>
|
||||
<DatePicker SelectedDate="{Binding DateTo}" Margin="0,0,10,6"/>
|
||||
<ComboBox ItemsSource="{Binding ReportTypes}" SelectedIndex="{Binding SelectedReportIndex}" Width="170" Margin="0,0,10,6"/>
|
||||
<Button Classes="primary" Content="Сформировать" Command="{Binding BuildCommand}" IsEnabled="{Binding !Busy}" Margin="0,0,0,6"/>
|
||||
</WrapPanel>
|
||||
|
||||
<Border Height="1" Background="{DynamicResource Border}"/>
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<TextBlock Text="Запрос ИИ" Classes="label" Margin="0,0,8,6"/>
|
||||
<TextBox Text="{Binding AiPrompt}" Width="420" Margin="0,0,10,6"
|
||||
Watermark="напр.: «закупки по поставщикам за июль», «заказы за всё время с товарами»"/>
|
||||
<ComboBox ItemsSource="{Binding AiFormats}" SelectedIndex="{Binding SelectedAiFormat}" Width="100" Margin="0,0,10,6"/>
|
||||
<Button Classes="primary" Content="Сформировать ИИ" Command="{Binding GenerateAiCommand}" IsEnabled="{Binding !AiBusy}" Margin="0,0,0,6"/>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Статус -->
|
||||
<Border DockPanel.Dock="Top" Margin="0,0,0,10" Padding="10,7" CornerRadius="6"
|
||||
Background="{DynamicResource AccentSoft}"
|
||||
IsVisible="{Binding Status, Converter={x:Static conv:ObjectConverters.IsNotNull}}">
|
||||
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
</Border>
|
||||
|
||||
<!-- Таблица -->
|
||||
<Border Classes="card" Padding="0" ClipToBounds="True">
|
||||
<DataGrid x:Name="Grid" ItemsSource="{Binding Rows}"
|
||||
AutoGenerateColumns="False" IsReadOnly="True"
|
||||
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False"/>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</UserControl>
|
||||
60
src/Elfisa.Avalonia/Views/ReportsView.axaml.cs
Normal file
60
src/Elfisa.Avalonia/Views/ReportsView.axaml.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Elfisa.Avalonia.ViewModels;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class ReportsView : UserControl
|
||||
{
|
||||
private ReportsViewModel? _vm;
|
||||
|
||||
public ReportsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_vm != null) _vm.ColumnsChanged -= RebuildColumns;
|
||||
_vm = DataContext as ReportsViewModel;
|
||||
if (_vm != null)
|
||||
{
|
||||
_vm.ColumnsChanged += RebuildColumns;
|
||||
RebuildColumns();
|
||||
}
|
||||
}
|
||||
|
||||
// DataGrid не умеет динамические колонки через биндинг — строим их из VM в коде.
|
||||
private void RebuildColumns()
|
||||
{
|
||||
Grid.Columns.Clear();
|
||||
if (_vm == null) return;
|
||||
|
||||
for (int i = 0; i < _vm.Columns.Count; i++)
|
||||
{
|
||||
int idx = i;
|
||||
var c = _vm.Columns[i];
|
||||
Grid.Columns.Add(new DataGridTemplateColumn
|
||||
{
|
||||
Header = c.Header,
|
||||
// одна тянущаяся колонка (star) заполняет свободное место, остальные фикс. пиксель
|
||||
Width = c.Stretch
|
||||
? new DataGridLength(1, DataGridLengthUnitType.Star)
|
||||
: new DataGridLength(c.Weight, DataGridLengthUnitType.Pixel),
|
||||
MinWidth = 56,
|
||||
CellTemplate = new FuncDataTemplate<ReportRow>((row, _) => new TextBlock
|
||||
{
|
||||
Text = row?[idx] ?? "",
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = c.Numeric ? TextAlignment.Right : TextAlignment.Left,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/Elfisa.Avalonia/Views/ShellView.axaml
Normal file
50
src/Elfisa.Avalonia/Views/ShellView.axaml
Normal file
@ -0,0 +1,50 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||
x:Class="Elfisa.Avalonia.Views.ShellView"
|
||||
x:DataType="vm:ShellViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="250,*">
|
||||
|
||||
<!-- Сайдбар -->
|
||||
<Border Grid.Column="0" Background="{DynamicResource Sidebar}">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="22,24,20,18" Spacing="2">
|
||||
<TextBlock Text="ЭльФиСА" FontSize="21" FontWeight="Bold" Foreground="White"/>
|
||||
<TextBlock Text="Электронная Фармация" Foreground="{DynamicResource SidebarText}" FontSize="11"/>
|
||||
</StackPanel>
|
||||
|
||||
<Border DockPanel.Dock="Bottom" Margin="14,10,14,16" Padding="14" CornerRadius="12"
|
||||
Background="{DynamicResource SidebarActive}">
|
||||
<StackPanel Spacing="9">
|
||||
<TextBlock Text="{Binding UserName}" Foreground="White" FontWeight="SemiBold"
|
||||
FontSize="12.5" TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding RoleLabel}" Foreground="{DynamicResource SidebarText}" FontSize="11"/>
|
||||
<Button Content="Выйти" Command="{Binding LogoutCommand}" HorizontalAlignment="Stretch"
|
||||
Background="Transparent" Foreground="White" BorderBrush="#55FFFFFF"
|
||||
BorderThickness="1" CornerRadius="8" Padding="0,7" Cursor="Hand"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox Classes="nav" Margin="0,6,0,0"
|
||||
ItemsSource="{Binding NavItems}" SelectedItem="{Binding SelectedNav}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:NavItem">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Border Width="26" Height="26" CornerRadius="7" Background="#26FFFFFF">
|
||||
<TextBlock Text="{Binding Icon}" Foreground="White" FontSize="12.5"
|
||||
FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Title}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Контент -->
|
||||
<ContentControl Grid.Column="1" Content="{Binding CurrentPage}"/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
9
src/Elfisa.Avalonia/Views/ShellView.axaml.cs
Normal file
9
src/Elfisa.Avalonia/Views/ShellView.axaml.cs
Normal file
@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace Elfisa.Avalonia.Views;
|
||||
|
||||
public partial class ShellView : UserControl
|
||||
{
|
||||
public ShellView() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
18
src/Elfisa.Avalonia/app.manifest
Normal file
18
src/Elfisa.Avalonia/app.manifest
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="Elfisa.Avalonia.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
130
src/Elfisa.Core/ApiClient.cs
Normal file
130
src/Elfisa.Core/ApiClient.cs
Normal file
@ -0,0 +1,130 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Elfisa.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Клиент платформы. Портирован из WinForms-версии, оставлены методы,
|
||||
/// нужные новому Avalonia-клиенту: логин, отчёт покупателя, ИИ-отчёт.
|
||||
/// Остальные эндпоинты добавляются по мере портирования экранов.
|
||||
/// </summary>
|
||||
public sealed class ApiClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = null };
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _baseUrl;
|
||||
private string? _token;
|
||||
|
||||
public ApiClient(string? baseUrl = null)
|
||||
{
|
||||
_baseUrl = (baseUrl ?? AppConfig.ApiBaseUrl).TrimEnd('/');
|
||||
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
|
||||
}
|
||||
|
||||
public string BaseUrl => _baseUrl;
|
||||
public string? Token => _token;
|
||||
public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
|
||||
|
||||
public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
|
||||
|
||||
private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
|
||||
|
||||
/// <summary>Логин. Возвращает (token, role) либо бросает с понятным сообщением.</summary>
|
||||
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||
throw new ApiException("Неверный логин или пароль.");
|
||||
if (resp.StatusCode == (HttpStatusCode)429)
|
||||
throw new ApiException("Слишком много запросов. Подождите немного и повторите.");
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new ApiException(TryError(text) ?? $"Ошибка авторизации ({(int)resp.StatusCode}).");
|
||||
|
||||
var login = JsonSerializer.Deserialize<LoginResponse>(text, Json);
|
||||
if (login is null || string.IsNullOrWhiteSpace(login.Token))
|
||||
throw new ApiException("Сервер вернул пустой токен.");
|
||||
_token = login.Token;
|
||||
return login;
|
||||
}
|
||||
|
||||
/// <summary>Отчёт покупателя за период (скоуп по JWT на сервере — только свои заказы).</summary>
|
||||
public async Task<List<BuyerReportLineDto>> GetBuyerReportAsync(DateTime from, DateTime to)
|
||||
{
|
||||
EnsureAuth();
|
||||
var path = $"/api/buyer/report?date_from={from:yyyy-MM-dd}&date_to={to:yyyy-MM-dd}";
|
||||
var text = await AuthorizedGetAsync(path, "формирование отчёта").ConfigureAwait(false);
|
||||
var resp = JsonSerializer.Deserialize<BuyerReportResponse>(text, Json);
|
||||
return resp?.Lines ?? new List<BuyerReportLineDto>();
|
||||
}
|
||||
|
||||
/// <summary>Свободный запрос к ИИ -> готовый Word/Excel (сервис на Mac mini через /ai).</summary>
|
||||
public async Task<AiReportResult> GenerateAiReportAsync(string prompt, string format = "docx")
|
||||
{
|
||||
EnsureAuth();
|
||||
if (string.IsNullOrWhiteSpace(prompt)) throw new ApiException("Пустой запрос для ИИ.");
|
||||
var aiUrl = new Uri(new Uri(AppConfig.AiBaseUrl.TrimEnd('/') + "/"), "generate").ToString();
|
||||
var body = JsonSerializer.Serialize(new AiReportRequest { Prompt = prompt, Token = _token!, Format = format }, Json);
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, aiUrl)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"ИИ-сервис недоступен: {ex.Message}", ex); }
|
||||
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var err = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
throw new ApiException($"ИИ-сервис вернул {(int)resp.StatusCode}: {TryError(err) ?? err}");
|
||||
}
|
||||
var bytes = await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
|
||||
var name = resp.Content.Headers.ContentDisposition?.FileNameStar
|
||||
?? resp.Content.Headers.ContentDisposition?.FileName
|
||||
?? (format == "xlsx" ? "Отчёт.xlsx" : "Отчёт.docx");
|
||||
return new AiReportResult { Content = bytes, FileName = name.Trim('"') };
|
||||
}
|
||||
|
||||
private async Task<string> AuthorizedGetAsync(string path, string op)
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, Url(path));
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
||||
HttpResponseMessage resp;
|
||||
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
|
||||
|
||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
if (resp.StatusCode == HttpStatusCode.Unauthorized) { _token = null; throw new ApiException("Сессия истекла, войдите снова."); }
|
||||
if (!resp.IsSuccessStatusCode) throw new ApiException(TryError(text) ?? $"Ошибка сервера ({(int)resp.StatusCode}) при {op}.");
|
||||
return text;
|
||||
}
|
||||
|
||||
private void EnsureAuth()
|
||||
{
|
||||
if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
|
||||
}
|
||||
|
||||
private static string? TryError(string body)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body)) return null;
|
||||
try { return JsonSerializer.Deserialize<ErrorResponse>(body, Json)?.Error; }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ApiException : Exception
|
||||
{
|
||||
public ApiException(string message, Exception? inner = null) : base(message, inner) { }
|
||||
}
|
||||
33
src/Elfisa.Core/AppConfig.cs
Normal file
33
src/Elfisa.Core/AppConfig.cs
Normal file
@ -0,0 +1,33 @@
|
||||
namespace Elfisa.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Базовые адреса платформы. Совпадают с десктопом WinForms:
|
||||
/// API — https://24pharmdata.ru, ИИ-отчёты — https://24pharmdata.ru/ai.
|
||||
/// Можно переопределить переменными окружения.
|
||||
/// </summary>
|
||||
public static class AppConfig
|
||||
{
|
||||
public const string DefaultApiBaseUrl = "https://24pharmdata.ru";
|
||||
public const string DefaultAiBaseUrl = "https://24pharmdata.ru/ai";
|
||||
|
||||
private const string ApiEnv = "ELF_API_BASE_URL";
|
||||
private const string AiEnv = "ELF_AI_BASE_URL";
|
||||
|
||||
public static string ApiBaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var env = Environment.GetEnvironmentVariable(ApiEnv);
|
||||
return string.IsNullOrWhiteSpace(env) ? DefaultApiBaseUrl : env.Trim().TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
public static string AiBaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var env = Environment.GetEnvironmentVariable(AiEnv);
|
||||
return string.IsNullOrWhiteSpace(env) ? DefaultAiBaseUrl : env.Trim().TrimEnd('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/Elfisa.Core/Dtos.cs
Normal file
54
src/Elfisa.Core/Dtos.cs
Normal file
@ -0,0 +1,54 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Elfisa.Core;
|
||||
|
||||
public sealed class LoginRequest
|
||||
{
|
||||
[JsonPropertyName("username")] public string Username { get; set; } = "";
|
||||
[JsonPropertyName("password")] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class LoginResponse
|
||||
{
|
||||
[JsonPropertyName("token")] public string? Token { get; set; }
|
||||
[JsonPropertyName("role")] public string? Role { get; set; }
|
||||
[JsonPropertyName("redirect_url")] public string? RedirectUrl { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BuyerReportResponse
|
||||
{
|
||||
[JsonPropertyName("lines")] public List<BuyerReportLineDto>? Lines { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BuyerReportLineDto
|
||||
{
|
||||
[JsonPropertyName("order_id")] public string? OrderId { get; set; }
|
||||
[JsonPropertyName("global_sign")] public string? GlobalSign { get; set; }
|
||||
[JsonPropertyName("order_date")] public DateTime? OrderDate { get; set; }
|
||||
[JsonPropertyName("status")] public string? Status { get; set; }
|
||||
[JsonPropertyName("supplier")] public string? Supplier { get; set; }
|
||||
[JsonPropertyName("location")] public string? Location { get; set; }
|
||||
[JsonPropertyName("item_name")] public string? ItemName { get; set; }
|
||||
[JsonPropertyName("item_code")] public string? ItemCode { get; set; }
|
||||
[JsonPropertyName("qty")] public decimal Qty { get; set; }
|
||||
[JsonPropertyName("unit_price")] public decimal UnitPrice { get; set; }
|
||||
[JsonPropertyName("sum")] public decimal Sum { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AiReportRequest
|
||||
{
|
||||
[JsonPropertyName("prompt")] public string Prompt { get; set; } = "";
|
||||
[JsonPropertyName("token")] public string Token { get; set; } = "";
|
||||
[JsonPropertyName("format")] public string Format { get; set; } = "docx";
|
||||
}
|
||||
|
||||
public sealed class AiReportResult
|
||||
{
|
||||
public byte[] Content { get; set; } = Array.Empty<byte>();
|
||||
public string FileName { get; set; } = "report.docx";
|
||||
}
|
||||
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
[JsonPropertyName("error")] public string? Error { get; set; }
|
||||
}
|
||||
9
src/Elfisa.Core/Elfisa.Core.csproj
Normal file
9
src/Elfisa.Core/Elfisa.Core.csproj
Normal file
@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
20
src/Elfisa.Core/Session.cs
Normal file
20
src/Elfisa.Core/Session.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace Elfisa.Core;
|
||||
|
||||
/// <summary>Текущая сессия пользователя (токен, роль, логин).</summary>
|
||||
public sealed class Session
|
||||
{
|
||||
public static Session Current { get; } = new();
|
||||
|
||||
public string? Token { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? Username { get; set; }
|
||||
|
||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
Token = null;
|
||||
Role = null;
|
||||
Username = null;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user