diff --git a/Elfisa.Cross.slnx b/Elfisa.Cross.slnx new file mode 100644 index 0000000..4a27b69 --- /dev/null +++ b/Elfisa.Cross.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Elfisa.Avalonia/App.axaml b/src/Elfisa.Avalonia/App.axaml new file mode 100644 index 0000000..dd9e0f1 --- /dev/null +++ b/src/Elfisa.Avalonia/App.axaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + #0F766E + + + + + + + + + + + + + + + + + diff --git a/src/Elfisa.Avalonia/App.axaml.cs b/src/Elfisa.Avalonia/App.axaml.cs new file mode 100644 index 0000000..cba5ce7 --- /dev/null +++ b/src/Elfisa.Avalonia/App.axaml.cs @@ -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(); + } +} \ No newline at end of file diff --git a/src/Elfisa.Avalonia/Assets/avalonia-logo.ico b/src/Elfisa.Avalonia/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/src/Elfisa.Avalonia/Assets/avalonia-logo.ico differ diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj new file mode 100644 index 0000000..ed75769 --- /dev/null +++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj @@ -0,0 +1,31 @@ + + + WinExe + net8.0 + enable + app.manifest + true + + + + + + + + + + + + + + + None + All + + + + + + + + diff --git a/src/Elfisa.Avalonia/Program.cs b/src/Elfisa.Avalonia/Program.cs new file mode 100644 index 0000000..ea1dc00 --- /dev/null +++ b/src/Elfisa.Avalonia/Program.cs @@ -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() + .UsePlatformDetect() +#if DEBUG + .WithDeveloperTools() +#endif + .WithInterFont() + .LogToTrace(); +} diff --git a/src/Elfisa.Avalonia/README.md b/src/Elfisa.Avalonia/README.md new file mode 100644 index 0000000..d716404 --- /dev/null +++ b/src/Elfisa.Avalonia/README.md @@ -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), что и есть основная работа. diff --git a/src/Elfisa.Avalonia/Styles/Elfisa.axaml b/src/Elfisa.Avalonia/Styles/Elfisa.axaml new file mode 100644 index 0000000..447711b --- /dev/null +++ b/src/Elfisa.Avalonia/Styles/Elfisa.axaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Elfisa.Avalonia/ViewLocator.cs b/src/Elfisa.Avalonia/ViewLocator.cs new file mode 100644 index 0000000..e5eca54 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Elfisa.Avalonia.ViewModels; + +namespace Elfisa.Avalonia; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[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; + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs b/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs new file mode 100644 index 0000000..b7520be --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/LoginViewModel.cs @@ -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; } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs b/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs new file mode 100644 index 0000000..86a9ca1 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/PlaceholderPageViewModel.cs @@ -0,0 +1,15 @@ +namespace Elfisa.Avalonia.ViewModels; + +/// Заглушка ещё не портированного раздела. +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; + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs b/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs new file mode 100644 index 0000000..b438037 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/ReportsViewModel.cs @@ -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) +} + +/// Строка отчёта: ячейки как строки, доступ по индексу для DataGrid. +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 _lines = new(); + + /// Сигнал View пересобрать колонки DataGrid. + public event Action? ColumnsChanged; + + public ObservableCollection Columns { get; } = new(); + public ObservableCollection 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 cols, List rows) Aggregate(List 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 + { + 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 + { + 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 + { + 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 + { + 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 { /* открыть не удалось — путь показан в статусе */ } + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs new file mode 100644 index 0000000..6016726 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs @@ -0,0 +1,48 @@ +using System; +using Elfisa.Core; + +namespace Elfisa.Avalonia.ViewModels; + +/// Корневой VM: переключает экран входа и основной шелл. +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); + } +} diff --git a/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs b/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs new file mode 100644 index 0000000..f16eae6 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/ShellViewModel.cs @@ -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 Factory { get; } + public NavItem(string title, string icon, Func factory) + { + Title = title; Icon = icon; Factory = factory; + } +} + +public partial class ShellViewModel : ViewModelBase +{ + private readonly Action _logout; + + public ObservableCollection 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 + { + 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(); +} diff --git a/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs b/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..da8f4b2 --- /dev/null +++ b/src/Elfisa.Avalonia/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Elfisa.Avalonia.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/src/Elfisa.Avalonia/Views/LoginView.axaml b/src/Elfisa.Avalonia/Views/LoginView.axaml new file mode 100644 index 0000000..6c89397 --- /dev/null +++ b/src/Elfisa.Avalonia/Views/LoginView.axaml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + +