������� �� Avalonia 2.0 (�����-���������, ����������, �������� ��������) #1

Merged
Exest merged 18 commits from feature/avalonia-ui into master 2026-08-11 09:41:09 +03:00
31 changed files with 1329 additions and 0 deletions
Showing only changes of commit f6a1012e9b - Show all commits

6
Elfisa.Cross.slnx Normal file
View 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>

View 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>

View 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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

View 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>

View 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();
}

View 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), что и есть основная работа.

View 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>

View 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;
}
}

View 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; }
}
}

View 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;
}
}

View 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 { /* открыть не удалось — путь показан в статусе */ }
}
}

View 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);
}
}

View 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();
}

View File

@ -0,0 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Elfisa.Avalonia.ViewModels;
public abstract class ViewModelBase : ObservableObject
{
}

View 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>

View 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);
}

View 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>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Elfisa.Avalonia.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View 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>

View 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);
}

View 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>

View 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
})
});
}
}
}

View 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>

View 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);
}

View 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>

View 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) { }
}

View 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
View 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; }
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View 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;
}
}