Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a024ba10 |
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define MyAppName "Электронная Фармация"
|
#define MyAppName "Электронная Фармация"
|
||||||
#define MyAppBrand "ЭльФиСА"
|
#define MyAppBrand "ЭльФиСА"
|
||||||
#define MyAppVersion "2.0.4"
|
#define MyAppVersion "2.0.1"
|
||||||
#define MyAppPublisher "PharmData"
|
#define MyAppPublisher "PharmData"
|
||||||
#define MyAppURL "https://cdn.24pharmdata.ru"
|
#define MyAppURL "https://cdn.24pharmdata.ru"
|
||||||
#define MyAppSupportURL "https://24pharmdata.ru"
|
#define MyAppSupportURL "https://24pharmdata.ru"
|
||||||
|
|||||||
@ -5,9 +5,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<Version>2.0.4</Version>
|
<Version>2.0.1</Version>
|
||||||
<AssemblyVersion>2.0.4.0</AssemblyVersion>
|
<AssemblyVersion>2.0.1.0</AssemblyVersion>
|
||||||
<FileVersion>2.0.4.0</FileVersion>
|
<FileVersion>2.0.1.0</FileVersion>
|
||||||
<Product>ЭльФиСА — Электронная Фармация</Product>
|
<Product>ЭльФиСА — Электронная Фармация</Product>
|
||||||
<Company>PharmData</Company>
|
<Company>PharmData</Company>
|
||||||
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
|
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
|
||||||
|
|||||||
@ -61,9 +61,7 @@ public partial class LoadPriceViewModel : ViewModelBase
|
|||||||
ProgressText = "Соединение с сервером…";
|
ProgressText = "Соединение с сервером…";
|
||||||
|
|
||||||
var all = new List<PriceItem>();
|
var all = new List<PriceItem>();
|
||||||
// Большая страница: сервер на каждый запрос заново прогоняет тяжёлый
|
const int page = 500;
|
||||||
// сводный запрос, поэтому одна большая страница в разы быстрее десятков мелких.
|
|
||||||
const int page = 20000;
|
|
||||||
int offset = 0, total = 0;
|
int offset = 0, total = 0;
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
@ -79,7 +77,7 @@ public partial class LoadPriceViewModel : ViewModelBase
|
|||||||
: $"Загружено {all.Count} позиций…";
|
: $"Загружено {all.Count} позиций…";
|
||||||
|
|
||||||
offset += batch.Count;
|
offset += batch.Count;
|
||||||
if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 500000)
|
if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,6 @@ public partial class RootViewModel : ViewModelBase
|
|||||||
|
|
||||||
public RootViewModel()
|
public RootViewModel()
|
||||||
{
|
{
|
||||||
// Запомнили вход в прошлый раз и токен ещё живой — сразу в программу.
|
|
||||||
if (Session.TryRestore())
|
|
||||||
{
|
|
||||||
Current = new ShellViewModel(Logout);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Current = new LoginViewModel(OnLoggedIn);
|
Current = new LoginViewModel(OnLoggedIn);
|
||||||
TryDevAutoLogin();
|
TryDevAutoLogin();
|
||||||
}
|
}
|
||||||
@ -44,11 +38,7 @@ public partial class RootViewModel : ViewModelBase
|
|||||||
catch { /* остаёмся на экране входа */ }
|
catch { /* остаёмся на экране входа */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLoggedIn()
|
private void OnLoggedIn() => Current = new ShellViewModel(Logout);
|
||||||
{
|
|
||||||
Session.Current.Save(); // запомнить вход между запусками
|
|
||||||
Current = new ShellViewModel(Logout);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Logout()
|
private void Logout()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -9,119 +9,98 @@ using Elfisa.Core;
|
|||||||
namespace Elfisa.Avalonia.ViewModels;
|
namespace Elfisa.Avalonia.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Отправить — отправка НЕОТПРАВЛЕННЫХ накладных (черновиков заказов из раздела
|
/// Отправить: поиск по прайсу → корзина (общая с Прайс-листом) → создание заказа.
|
||||||
/// «Заказы»). Список готовых накладных, кнопка «Отправить» (создаёт реальный заказ
|
/// ВНИМАНИЕ: отправка создаёт реальный заказ у поставщика.
|
||||||
/// у поставщика). Без поиска по прайсу и наименований — только сами накладные.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class SendOrderViewModel : ViewModelBase
|
public partial class SendOrderViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly ApiClient _api = new();
|
private readonly ApiClient _api = new();
|
||||||
|
private readonly CartService _cart = CartService.Instance;
|
||||||
|
|
||||||
/// <summary>Неотправленные накладные (черновики).</summary>
|
public ObservableCollection<PriceItem> Results { get; } = new();
|
||||||
public ObservableCollection<DraftOrder> Drafts { get; } = new();
|
public ObservableCollection<CartItem> Cart => _cart.Items;
|
||||||
|
public ObservableCollection<BuyerLocationDto> Locations { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private DraftOrder? _selected;
|
[ObservableProperty] private string _search = "";
|
||||||
|
[ObservableProperty] private PriceItem? _selectedResult;
|
||||||
|
[ObservableProperty] private BuyerLocationDto? _selectedLocation;
|
||||||
|
[ObservableProperty] private string _comment = "";
|
||||||
[ObservableProperty] private bool _busy;
|
[ObservableProperty] private bool _busy;
|
||||||
[ObservableProperty] private string? _status;
|
[ObservableProperty] private string? _status;
|
||||||
[ObservableProperty] private decimal _totalSum;
|
[ObservableProperty] private decimal _cartTotal;
|
||||||
[ObservableProperty] private bool _isEmpty = true;
|
|
||||||
[ObservableProperty] private int _count;
|
|
||||||
|
|
||||||
public SendOrderViewModel()
|
public SendOrderViewModel()
|
||||||
{
|
{
|
||||||
_api.SetToken(Session.Current.Token);
|
_api.SetToken(Session.Current.Token);
|
||||||
Load();
|
_cart.Changed += Recalc;
|
||||||
|
Recalc();
|
||||||
|
if (Session.Current.IsAuthenticated) _ = InitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var locs = await _api.GetBuyerLocationsAsync();
|
||||||
|
Locations.Clear();
|
||||||
|
foreach (var l in locs) Locations.Add(l);
|
||||||
|
SelectedLocation = Locations.FirstOrDefault(l => l.IsDefault) ?? Locations.FirstOrDefault();
|
||||||
|
await SearchAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Refresh() => Load();
|
private async Task SearchAsync()
|
||||||
|
|
||||||
private void Load()
|
|
||||||
{
|
{
|
||||||
Drafts.Clear();
|
try
|
||||||
foreach (var d in DraftStore.All.OrderByDescending(x => x.CreatedAt))
|
{
|
||||||
Drafts.Add(d);
|
Busy = true; Status = null;
|
||||||
TotalSum = Drafts.Sum(d => d.Total);
|
var resp = await _api.GetPriceSummaryAsync(Search, limit: 200);
|
||||||
Count = Drafts.Count;
|
Results.Clear();
|
||||||
IsEmpty = Drafts.Count == 0;
|
foreach (var it in resp.Summary ?? new()) Results.Add(it);
|
||||||
if (Selected is null || !Drafts.Contains(Selected))
|
}
|
||||||
Selected = Drafts.FirstOrDefault();
|
catch (Exception ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Отправить одну накладную.</summary>
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SendAsync(DraftOrder? draft)
|
private void AddToCart()
|
||||||
|
{
|
||||||
|
if (SelectedResult is null) { Status = "Выберите позицию в списке слева."; return; }
|
||||||
|
_cart.Add(SelectedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void RemoveFromCart(CartItem? item) => _cart.Remove(item!);
|
||||||
|
|
||||||
|
private void Recalc() => CartTotal = _cart.Total;
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SendAsync()
|
||||||
{
|
{
|
||||||
var d = draft ?? Selected;
|
|
||||||
if (d is null) { Status = "Выберите накладную для отправки."; return; }
|
|
||||||
Status = null;
|
Status = null;
|
||||||
|
if (_cart.Count == 0) { Status = "Корзина пуста."; return; }
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Busy = true;
|
Busy = true;
|
||||||
var resp = await CreateFromDraft(d);
|
|
||||||
DraftStore.Remove(d.Id);
|
|
||||||
Status = $"✓ Накладная {resp.GlobalSign ?? resp.OrderId} отправлена ({resp.ItemsCount} поз., {resp.TotalAmount:N2}).";
|
|
||||||
}
|
|
||||||
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
|
|
||||||
finally { Busy = false; Load(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Отправить все неотправленные накладные.</summary>
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task SendAllAsync()
|
|
||||||
{
|
|
||||||
if (Drafts.Count == 0) { Status = "Нет неотправленных накладных."; return; }
|
|
||||||
Status = null;
|
|
||||||
int ok = 0;
|
|
||||||
string? lastErr = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Busy = true;
|
|
||||||
foreach (var d in DraftStore.All.ToList())
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await CreateFromDraft(d);
|
|
||||||
DraftStore.Remove(d.Id);
|
|
||||||
ok++;
|
|
||||||
}
|
|
||||||
catch (Exception ex) { lastErr = ex.Message; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally { Busy = false; Load(); }
|
|
||||||
|
|
||||||
Status = lastErr is null
|
|
||||||
? $"✓ Отправлено накладных: {ok}."
|
|
||||||
: $"Отправлено: {ok}. Не удалось часть — {lastErr}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Удалить неотправленную накладную (не отправляя).</summary>
|
|
||||||
[RelayCommand]
|
|
||||||
private void Delete(DraftOrder? draft)
|
|
||||||
{
|
|
||||||
var d = draft ?? Selected;
|
|
||||||
if (d is null) return;
|
|
||||||
DraftStore.Remove(d.Id);
|
|
||||||
Load();
|
|
||||||
Status = "Накладная удалена.";
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<BuyerOrderResponse> CreateFromDraft(DraftOrder d)
|
|
||||||
{
|
|
||||||
var req = new BuyerOrderCreateRequest
|
var req = new BuyerOrderCreateRequest
|
||||||
{
|
{
|
||||||
// Номер EX резервировался при сохранении черновика — используем его.
|
LocationId = SelectedLocation?.BuyerLocationId,
|
||||||
GlobalSign = d.Number.StartsWith("EX-", StringComparison.OrdinalIgnoreCase) ? d.Number : null,
|
Comment = string.IsNullOrWhiteSpace(Comment) ? null : Comment.Trim(),
|
||||||
LocationId = d.LocationId,
|
Items = _cart.Items.Select(c => new BuyerOrderItemReq
|
||||||
Comment = d.Comment,
|
|
||||||
Items = d.Items.Select(i => new BuyerOrderItemReq
|
|
||||||
{
|
{
|
||||||
SupplierPriceId = i.SupplierPriceId,
|
SupplierPriceId = c.Source.SupplierPriceId ?? "",
|
||||||
Qty = (double)i.Qty,
|
Qty = (double)c.Qty,
|
||||||
ItemName = i.Name,
|
ItemName = c.Source.DrugName,
|
||||||
Barcode = i.Barcode
|
Barcode = c.Source.Barcode
|
||||||
}).ToList()
|
}).ToList()
|
||||||
};
|
};
|
||||||
return await _api.CreateOrderAsync(req);
|
var resp = await _api.CreateOrderAsync(req);
|
||||||
|
Status = $"✓ Заказ {resp.GlobalSign ?? resp.OrderId} создан: {resp.ItemsCount} поз., {resp.TotalAmount:N2}.";
|
||||||
|
_cart.Clear();
|
||||||
|
}
|
||||||
|
catch (Exception ex) { Status = "Ошибка отправки: " + ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using Elfisa.Core;
|
|
||||||
|
|
||||||
namespace Elfisa.Avalonia.ViewModels;
|
|
||||||
|
|
||||||
/// <summary>Окно «Доступно обновление»: скачивает установщик и запускает его.</summary>
|
|
||||||
public partial class UpdateViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
private readonly UpdateInfo _info;
|
|
||||||
private readonly Action _close;
|
|
||||||
|
|
||||||
public UpdateViewModel(UpdateInfo info, Action close)
|
|
||||||
{
|
|
||||||
_info = info;
|
|
||||||
_close = close;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Message =>
|
|
||||||
$"Установлена версия {_info.CurrentVersion}.\nДоступна новая версия {_info.LatestVersion}.";
|
|
||||||
|
|
||||||
[ObservableProperty] private bool _busy;
|
|
||||||
[ObservableProperty] private double _progress;
|
|
||||||
[ObservableProperty] private string _status = "";
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void Later() => _close();
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task UpdateAsync()
|
|
||||||
{
|
|
||||||
// Не-Windows: установщика нет — открываем страницу релиза в браузере.
|
|
||||||
if (!OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
OpenUrl(string.IsNullOrWhiteSpace(_info.ReleaseUrl) ? "https://cdn.24pharmdata.ru" : _info.ReleaseUrl);
|
|
||||||
_close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Busy = true;
|
|
||||||
Status = "Скачивание обновления…";
|
|
||||||
var progress = new Progress<double>(p =>
|
|
||||||
{
|
|
||||||
Progress = p;
|
|
||||||
Status = $"Скачивание обновления… {p:0}%";
|
|
||||||
});
|
|
||||||
var setupPath = await UpdateService.DownloadSetupAsync(progress);
|
|
||||||
|
|
||||||
Status = "Запуск установки…";
|
|
||||||
LaunchInstallerAndExit(setupPath);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Busy = false;
|
|
||||||
Status = "Не удалось обновить: " + ex.Message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Тихая установка поверх + перезапуск приложения, затем закрываем себя.</summary>
|
|
||||||
private void LaunchInstallerAndExit(string setupPath)
|
|
||||||
{
|
|
||||||
var exe = Process.GetCurrentProcess().MainModule?.FileName;
|
|
||||||
var inner = $"\"{setupPath}\" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART";
|
|
||||||
if (!string.IsNullOrEmpty(exe))
|
|
||||||
inner += $" && start \"\" \"{exe}\"";
|
|
||||||
|
|
||||||
Process.Start(new ProcessStartInfo("cmd.exe")
|
|
||||||
{
|
|
||||||
Arguments = "/c \"" + inner + "\"",
|
|
||||||
UseShellExecute = false,
|
|
||||||
CreateNoWindow = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Закрываем приложение, чтобы установщик заменил файлы (CloseApplications=yes).
|
|
||||||
if (global::Avalonia.Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime d)
|
|
||||||
d.Shutdown();
|
|
||||||
else
|
|
||||||
Environment.Exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void OpenUrl(string url)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
|
||||||
}
|
|
||||||
catch { /* игнорируем */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +1,16 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
xmlns:vm="using:Elfisa.Avalonia.ViewModels"
|
||||||
xmlns:core="using:Elfisa.Core"
|
|
||||||
xmlns:conv="using:Avalonia.Data.Converters"
|
xmlns:conv="using:Avalonia.Data.Converters"
|
||||||
x:Class="Elfisa.Avalonia.Views.SendOrderView"
|
x:Class="Elfisa.Avalonia.Views.SendOrderView"
|
||||||
x:DataType="vm:SendOrderViewModel">
|
x:DataType="vm:SendOrderViewModel">
|
||||||
|
|
||||||
<DockPanel Margin="24">
|
<DockPanel Margin="24">
|
||||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,14" ColumnDefinitions="*,Auto">
|
<Grid DockPanel.Dock="Top" Margin="0,0,0,14">
|
||||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||||
<TextBlock Text="Отправить" Classes="h1"/>
|
<TextBlock Text="Отправить заказ" Classes="h1"/>
|
||||||
<TextBlock Text="Отправка неотправленных накладных из раздела «Заказы»" Classes="muted"/>
|
<TextBlock Text="Найдите позиции в прайсе, соберите корзину и отправьте поставщику" Classes="muted"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Content="Обновить" VerticalAlignment="Center"
|
|
||||||
Command="{Binding RefreshCommand}"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
|
<Border DockPanel.Dock="Bottom" Margin="0,10,0,0" Padding="10,7" CornerRadius="6"
|
||||||
@ -22,68 +19,86 @@
|
|||||||
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
|
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource AccentPressed}" TextWrapping="Wrap" FontSize="12.5"/>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Панель отправки: главное действие — отправить все -->
|
<Grid ColumnDefinitions="*,16,440">
|
||||||
<Border DockPanel.Dock="Top" Classes="card" Padding="16" Margin="0,0,0,12">
|
|
||||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
<!-- Прайс: поиск + результаты -->
|
||||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
<Border Grid.Column="0" Classes="card" Padding="0" ClipToBounds="True">
|
||||||
<TextBlock Foreground="{DynamicResource Text}" FontWeight="SemiBold" FontSize="15"
|
<DockPanel>
|
||||||
Text="{Binding Count, StringFormat='Неотправленных накладных: {0}'}"/>
|
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="10" Margin="14">
|
||||||
<TextBlock Classes="muted" FontSize="12.5"
|
<TextBox Text="{Binding Search}" Width="330" Watermark="поиск по наименованию…"/>
|
||||||
Text="{Binding TotalSum, StringFormat='на сумму {0:N2}'}"/>
|
<Button Classes="primary" Content="Найти" Command="{Binding SearchCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Classes="primary" Padding="26,12" FontSize="15"
|
<Button DockPanel.Dock="Bottom" Classes="primary" Content="Добавить в заказ ▸"
|
||||||
Content="Отправить все ▸" Command="{Binding SendAllCommand}"
|
Margin="14" HorizontalAlignment="Left"
|
||||||
IsEnabled="{Binding !Busy}" IsVisible="{Binding !IsEmpty}"/>
|
Command="{Binding AddToCartCommand}"/>
|
||||||
</Grid>
|
<DataGrid ItemsSource="{Binding Results}" SelectedItem="{Binding SelectedResult}"
|
||||||
|
x:CompileBindings="False" AutoGenerateColumns="False" IsReadOnly="True"
|
||||||
|
HeadersVisibility="Column" CanUserResizeColumns="True" CanUserSortColumns="False">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Наименование" Width="*" Binding="{Binding DrugName}"/>
|
||||||
|
<DataGridTextColumn Header="Поставщик" Width="120" Binding="{Binding SupplierName}"/>
|
||||||
|
<DataGridTextColumn Header="Цена" Width="100" Binding="{Binding Price, StringFormat='{}{0:N2}'}" CellStyleClasses="num"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Список накладных -->
|
<!-- Корзина -->
|
||||||
<Border Classes="card" Padding="0" ClipToBounds="True">
|
<Border Grid.Column="2" Classes="card" Padding="16">
|
||||||
<Grid>
|
<DockPanel>
|
||||||
<!-- Пусто -->
|
<TextBlock DockPanel.Dock="Top" Text="Корзина" Classes="h2" Margin="0,0,0,10"/>
|
||||||
<StackPanel IsVisible="{Binding IsEmpty}" VerticalAlignment="Center" HorizontalAlignment="Center"
|
|
||||||
Spacing="8" Margin="24">
|
<StackPanel DockPanel.Dock="Bottom" Spacing="10" Margin="0,12,0,0">
|
||||||
<TextBlock Text="📭" FontSize="34" HorizontalAlignment="Center"/>
|
<StackPanel Spacing="5">
|
||||||
<TextBlock Text="Нет неотправленных накладных" Classes="h2" HorizontalAlignment="Center"/>
|
<TextBlock Text="Грузополучатель" Classes="muted"/>
|
||||||
<TextBlock Classes="muted" HorizontalAlignment="Center" TextAlignment="Center" MaxWidth="360" TextWrapping="Wrap"
|
<ComboBox ItemsSource="{Binding Locations}" SelectedItem="{Binding SelectedLocation}"
|
||||||
Text="Соберите заказ в разделе «Прайс-лист» и нажмите «Сохранить заказ» — он появится здесь для отправки."/>
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBox Text="{Binding Comment}" Watermark="комментарий к заказу (необязательно)"/>
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="Итого" FontWeight="SemiBold" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource Text}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding CartTotal, StringFormat='{}{0:N2}'}"
|
||||||
|
FontWeight="Bold" FontSize="16" Foreground="{DynamicResource Accent}"/>
|
||||||
|
</Grid>
|
||||||
|
<Button Classes="primary" Content="Отправить заказ" HorizontalAlignment="Stretch"
|
||||||
|
Command="{Binding SendCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Есть накладные -->
|
<ScrollViewer>
|
||||||
<ScrollViewer IsVisible="{Binding !IsEmpty}">
|
<ItemsControl ItemsSource="{Binding Cart}" x:CompileBindings="False">
|
||||||
<ItemsControl ItemsSource="{Binding Drafts}" x:CompileBindings="False" Margin="0,0,0,14">
|
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="core:DraftOrder">
|
<DataTemplate x:DataType="vm:CartItem">
|
||||||
<Border Padding="14" Margin="14,14,14,0" CornerRadius="10"
|
<Border Padding="10" Margin="0,0,0,8" CornerRadius="9"
|
||||||
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
Background="{DynamicResource RowAlt}" BorderThickness="1" BorderBrush="{DynamicResource Border}">
|
||||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
<DockPanel>
|
||||||
<StackPanel Grid.Column="0" Spacing="3">
|
<Button DockPanel.Dock="Right" Content="✕" Width="30" Height="30" Padding="0"
|
||||||
<TextBlock Text="{Binding Number}" FontWeight="Bold" FontSize="15" Foreground="{DynamicResource Text}"/>
|
VerticalAlignment="Top" Background="Transparent"
|
||||||
<TextBlock Classes="muted" FontSize="12.5"
|
Foreground="{DynamicResource Muted}" Cursor="Hand"
|
||||||
Text="{Binding CreatedAt, StringFormat='dd.MM.yyyy HH:mm'}"/>
|
Command="{Binding $parent[ItemsControl].DataContext.RemoveFromCartCommand}"
|
||||||
<TextBlock Classes="muted" FontSize="12.5" TextTrimming="CharacterEllipsis"
|
|
||||||
Text="{Binding LocationAddress}"/>
|
|
||||||
<TextBlock Classes="muted" FontSize="12.5"
|
|
||||||
Text="{Binding ItemsCount, StringFormat='Позиций: {0}'}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding Total, StringFormat='{}{0:N2}'}" FontWeight="Bold"
|
|
||||||
FontSize="16" Foreground="{DynamicResource Accent}" VerticalAlignment="Center"/>
|
|
||||||
<Button Classes="primary" Content="Отправить"
|
|
||||||
Command="{Binding $parent[ItemsControl].DataContext.SendCommand}"
|
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
<Button Content="✕" Width="32" Height="32" Padding="0" VerticalAlignment="Center"
|
<StackPanel Spacing="6">
|
||||||
Background="Transparent" Foreground="{DynamicResource Muted}" Cursor="Hand"
|
<TextBlock Text="{Binding Name}" Foreground="{DynamicResource Text}"
|
||||||
Command="{Binding $parent[ItemsControl].DataContext.DeleteCommand}"
|
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"/>
|
||||||
CommandParameter="{Binding}"/>
|
<TextBlock Text="{Binding Supplier}" Classes="muted"/>
|
||||||
</StackPanel>
|
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||||
|
<NumericUpDown Grid.Column="0" Value="{Binding Qty}" Minimum="1" Maximum="100000"
|
||||||
|
Increment="1" FormatString="0" Width="120"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding Price, StringFormat='× {0:N2}'}"
|
||||||
|
Classes="muted" VerticalAlignment="Center" Margin="10,0"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding LineSum, StringFormat='{}{0:N2}'}"
|
||||||
|
FontWeight="SemiBold" Foreground="{DynamicResource Text}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Grid>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@ -1,25 +1,15 @@
|
|||||||
using System;
|
|
||||||
using System.Reflection;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using Avalonia.Threading;
|
|
||||||
using Elfisa.Avalonia.ViewModels;
|
using Elfisa.Avalonia.ViewModels;
|
||||||
using Elfisa.Core;
|
|
||||||
|
|
||||||
namespace Elfisa.Avalonia.Views;
|
namespace Elfisa.Avalonia.Views;
|
||||||
|
|
||||||
public partial class ShellView : UserControl
|
public partial class ShellView : UserControl
|
||||||
{
|
{
|
||||||
private static bool _updateChecked; // один раз за запуск
|
public ShellView() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
public ShellView()
|
|
||||||
{
|
|
||||||
AvaloniaXamlLoader.Load(this);
|
|
||||||
Loaded += OnLoaded;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
|
private async void OnSettingsClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
@ -28,28 +18,4 @@ public partial class ShellView : UserControl
|
|||||||
if (owner is not null) await win.ShowDialog(owner);
|
if (owner is not null) await win.ShowDialog(owner);
|
||||||
else win.Show();
|
else win.Show();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверка обновлений при первом появлении шелла. Тихо игнорируем офлайн/ошибки.
|
|
||||||
private async void OnLoaded(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (_updateChecked) return;
|
|
||||||
_updateChecked = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var current = Assembly.GetEntryAssembly()?.GetName().Version ?? new Version(0, 0, 0, 0);
|
|
||||||
var info = await UpdateService.CheckAsync(current);
|
|
||||||
if (info is not { Available: true }) return;
|
|
||||||
|
|
||||||
await Dispatcher.UIThread.InvokeAsync(async () =>
|
|
||||||
{
|
|
||||||
var win = new UpdateWindow();
|
|
||||||
win.DataContext = new UpdateViewModel(info, win.Close);
|
|
||||||
var owner = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
|
||||||
if (owner is not null) await win.ShowDialog(owner);
|
|
||||||
else win.Show();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch { /* обновление не должно мешать работе */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
<Window 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.UpdateWindow"
|
|
||||||
x:DataType="vm:UpdateViewModel"
|
|
||||||
Width="440" Height="240" CanResize="False"
|
|
||||||
WindowStartupLocation="CenterOwner"
|
|
||||||
Background="{DynamicResource Bg}"
|
|
||||||
Title="Обновление">
|
|
||||||
|
|
||||||
<StackPanel Margin="24" Spacing="16">
|
|
||||||
<TextBlock Text="Доступно обновление" Classes="h1"/>
|
|
||||||
|
|
||||||
<TextBlock Text="{Binding Message}" TextWrapping="Wrap"/>
|
|
||||||
|
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding Busy}">
|
|
||||||
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" Height="8"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBlock Text="{Binding Status}" Foreground="{DynamicResource Muted}"
|
|
||||||
TextWrapping="Wrap" IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="10" Margin="0,4,0,0">
|
|
||||||
<Button Content="Позже" Command="{Binding LaterCommand}" IsEnabled="{Binding !Busy}" Padding="18,9"/>
|
|
||||||
<Button Classes="primary" Content="Обновить" Command="{Binding UpdateCommand}" IsEnabled="{Binding !Busy}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Window>
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Markup.Xaml;
|
|
||||||
|
|
||||||
namespace Elfisa.Avalonia.Views;
|
|
||||||
|
|
||||||
public partial class UpdateWindow : Window
|
|
||||||
{
|
|
||||||
public UpdateWindow() => AvaloniaXamlLoader.Load(this);
|
|
||||||
}
|
|
||||||
@ -37,14 +37,12 @@ public sealed class ApiClient
|
|||||||
public async Task<LoginResponse> LoginAsync(string username, string password)
|
public async Task<LoginResponse> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
|
var body = JsonSerializer.Serialize(new LoginRequest { Username = username, Password = password }, Json);
|
||||||
HttpResponseMessage resp;
|
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
||||||
try
|
|
||||||
{
|
|
||||||
resp = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
|
||||||
{
|
{
|
||||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||||
}).ConfigureAwait(false);
|
};
|
||||||
}
|
HttpResponseMessage resp;
|
||||||
|
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||||
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
|
catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); }
|
||||||
|
|
||||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||||
@ -166,16 +164,10 @@ public sealed class ApiClient
|
|||||||
|
|
||||||
private async Task<string> AuthorizedGetAsync(string path, string op)
|
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;
|
HttpResponseMessage resp;
|
||||||
try
|
try { resp = await _http.SendAsync(req).ConfigureAwait(false); }
|
||||||
{
|
|
||||||
resp = await SendWithRetryAsync(() =>
|
|
||||||
{
|
|
||||||
var r = new HttpRequestMessage(HttpMethod.Get, Url(path));
|
|
||||||
r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
|
|
||||||
return r;
|
|
||||||
}).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
|
catch (Exception ex) { throw new ApiException($"Не удалось выполнить {op}: {ex.Message}", ex); }
|
||||||
|
|
||||||
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||||
@ -184,21 +176,6 @@ public sealed class ApiClient
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Отправляет запрос с повтором на транзиентных сбоях (холодный TLS,
|
|
||||||
/// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза.</summary>
|
|
||||||
private async Task<HttpResponseMessage> SendWithRetryAsync(Func<HttpRequestMessage> make, int retries = 2)
|
|
||||||
{
|
|
||||||
Exception? last = null;
|
|
||||||
for (int attempt = 0; attempt <= retries; attempt++)
|
|
||||||
{
|
|
||||||
using var req = make();
|
|
||||||
try { return await _http.SendAsync(req).ConfigureAwait(false); }
|
|
||||||
catch (HttpRequestException ex) { last = ex; } // сеть/TLS — повторяем
|
|
||||||
if (attempt < retries) await Task.Delay(250 * (attempt + 1)).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
throw last!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureAuth()
|
private void EnsureAuth()
|
||||||
{
|
{
|
||||||
if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
|
if (!IsAuthenticated) throw new ApiException("Требуется авторизация.");
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
using System;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Elfisa.Core;
|
namespace Elfisa.Core;
|
||||||
|
|
||||||
/// <summary>Текущая сессия пользователя (токен, роль, логин). Сохраняется между запусками.</summary>
|
/// <summary>Текущая сессия пользователя (токен, роль, логин).</summary>
|
||||||
public sealed class Session
|
public sealed class Session
|
||||||
{
|
{
|
||||||
public static Session Current { get; } = new();
|
public static Session Current { get; } = new();
|
||||||
@ -14,57 +11,10 @@ public sealed class Session
|
|||||||
|
|
||||||
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token);
|
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token);
|
||||||
|
|
||||||
/// <summary>Сохранить сессию в настройки (чтобы не логиниться каждый раз).</summary>
|
|
||||||
public void Save()
|
|
||||||
{
|
|
||||||
SettingsStore.Current.Token = Token;
|
|
||||||
SettingsStore.Current.Role = Role;
|
|
||||||
SettingsStore.Current.Username = Username;
|
|
||||||
SettingsStore.Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Восстановить сессию из настроек, если токен есть и не истёк.</summary>
|
|
||||||
public static bool TryRestore()
|
|
||||||
{
|
|
||||||
var s = SettingsStore.Current;
|
|
||||||
if (string.IsNullOrWhiteSpace(s.Token) || IsJwtExpired(s.Token))
|
|
||||||
return false;
|
|
||||||
Current.Token = s.Token;
|
|
||||||
Current.Role = s.Role;
|
|
||||||
Current.Username = s.Username;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
Token = null;
|
Token = null;
|
||||||
Role = null;
|
Role = null;
|
||||||
Username = null;
|
Username = null;
|
||||||
SettingsStore.Current.Token = null;
|
|
||||||
SettingsStore.Current.Role = null;
|
|
||||||
SettingsStore.Current.Username = null;
|
|
||||||
SettingsStore.Save();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту.</summary>
|
|
||||||
private static bool IsJwtExpired(string token)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var parts = token.Split('.');
|
|
||||||
if (parts.Length != 3) return true;
|
|
||||||
var payload = parts[1].Replace('-', '+').Replace('_', '/');
|
|
||||||
switch (payload.Length % 4)
|
|
||||||
{
|
|
||||||
case 2: payload += "=="; break;
|
|
||||||
case 3: payload += "="; break;
|
|
||||||
}
|
|
||||||
var json = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
if (doc.RootElement.TryGetProperty("exp", out var exp) && exp.TryGetInt64(out var expUnix))
|
|
||||||
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() >= expUnix - 60;
|
|
||||||
return false; // нет exp — считаем валидным
|
|
||||||
}
|
|
||||||
catch { return true; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,96 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Elfisa.Core;
|
|
||||||
|
|
||||||
/// <summary>Результат проверки обновления.</summary>
|
|
||||||
public sealed class UpdateInfo
|
|
||||||
{
|
|
||||||
public bool Available { get; init; }
|
|
||||||
public string CurrentVersion { get; init; } = "";
|
|
||||||
public string LatestVersion { get; init; } = "";
|
|
||||||
public string ReleaseUrl { get; init; } = "";
|
|
||||||
public string SetupUrl { get; init; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Проверка и скачивание обновлений Avalonia-клиента.
|
|
||||||
/// Смотрит в Gitea Releases (тот же репозиторий, что и старый WinForms-апдейтер),
|
|
||||||
/// установщик тянет с CDN. Никаких токенов — репозиторий и CDN публичны.
|
|
||||||
/// </summary>
|
|
||||||
public static class UpdateService
|
|
||||||
{
|
|
||||||
private const string ReleasesLatest =
|
|
||||||
"https://git.24pharmdata.ru/api/v1/repos/pharmdata/elfisa-pharmacy/releases/latest";
|
|
||||||
|
|
||||||
/// <summary>Стабильный URL установщика на CDN (всегда последняя версия).</summary>
|
|
||||||
public const string SetupUrl = "https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe";
|
|
||||||
|
|
||||||
/// <summary>Проверяет наличие новой версии. Возвращает null при офлайне/ошибке (не мешаем работе).</summary>
|
|
||||||
public static async Task<UpdateInfo?> CheckAsync(Version current)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
|
|
||||||
http.DefaultRequestHeaders.Add("Accept", "application/json");
|
|
||||||
var json = await http.GetStringAsync(ReleasesLatest).ConfigureAwait(false);
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
var tag = root.TryGetProperty("tag_name", out var t) ? t.GetString() : null;
|
|
||||||
var latest = ParseVersion(tag);
|
|
||||||
if (latest is null) return null;
|
|
||||||
var htmlUrl = root.TryGetProperty("html_url", out var h) ? h.GetString() : null;
|
|
||||||
return new UpdateInfo
|
|
||||||
{
|
|
||||||
Available = latest > current,
|
|
||||||
CurrentVersion = current.ToString(),
|
|
||||||
LatestVersion = latest.ToString(),
|
|
||||||
ReleaseUrl = htmlUrl ?? "",
|
|
||||||
SetupUrl = SetupUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Скачивает установщик с CDN во временный файл. Возвращает путь.</summary>
|
|
||||||
public static async Task<string> DownloadSetupAsync(IProgress<double>? progress, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var path = Path.Combine(Path.GetTempPath(), "ElfisaPharmacy-Setup.exe");
|
|
||||||
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
|
||||||
using var resp = await http.GetAsync(SetupUrl, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
|
|
||||||
resp.EnsureSuccessStatusCode();
|
|
||||||
var total = resp.Content.Headers.ContentLength ?? -1;
|
|
||||||
await using var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
|
||||||
await using var dst = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
|
||||||
var buffer = new byte[81920];
|
|
||||||
long readTotal = 0;
|
|
||||||
int n;
|
|
||||||
while ((n = await src.ReadAsync(buffer, ct).ConfigureAwait(false)) > 0)
|
|
||||||
{
|
|
||||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct).ConfigureAwait(false);
|
|
||||||
readTotal += n;
|
|
||||||
if (total > 0) progress?.Report(readTotal * 100.0 / total);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Разбирает тег вида v2.0.4 / 2.0.4 / 2.0.4-beta в Version.</summary>
|
|
||||||
private static Version? ParseVersion(string? tag)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(tag)) return null;
|
|
||||||
tag = tag.Trim();
|
|
||||||
if (tag.StartsWith("v", StringComparison.OrdinalIgnoreCase)) tag = tag[1..];
|
|
||||||
var dash = tag.IndexOf('-');
|
|
||||||
if (dash > 0) tag = tag[..dash];
|
|
||||||
switch (tag.Split('.').Length)
|
|
||||||
{
|
|
||||||
case 1: tag += ".0.0.0"; break;
|
|
||||||
case 2: tag += ".0.0"; break;
|
|
||||||
case 3: tag += ".0"; break;
|
|
||||||
}
|
|
||||||
return Version.TryParse(tag, out var v) ? v : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -116,8 +116,6 @@
|
|||||||
background:var(--brand-soft); color:var(--brand-ink); border:1px solid color-mix(in srgb,var(--brand) 26%, transparent)}
|
background:var(--brand-soft); color:var(--brand-ink); border:1px solid color-mix(in srgb,var(--brand) 26%, transparent)}
|
||||||
.dl:hover{background:var(--brand); color:#fff}
|
.dl:hover{background:var(--brand); color:#fff}
|
||||||
.dl svg{width:17px; height:17px; fill:currentColor}
|
.dl svg{width:17px; height:17px; fill:currentColor}
|
||||||
.dl.soon{background:var(--panel-2); color:var(--muted); border-color:var(--line); cursor:default}
|
|
||||||
.dl.soon:hover{background:var(--panel-2); color:var(--muted)}
|
|
||||||
|
|
||||||
/* ---- features ---- */
|
/* ---- features ---- */
|
||||||
.feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px}
|
.feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px}
|
||||||
@ -175,7 +173,7 @@
|
|||||||
<div class="spec"><span>Формат</span><b>.exe установщик</b></div>
|
<div class="spec"><span>Формат</span><b>.exe установщик</b></div>
|
||||||
<div class="spec"><span>Система</span><b>Windows 10 / 11</b></div>
|
<div class="spec"><span>Система</span><b>Windows 10 / 11</b></div>
|
||||||
<div class="spec"><span>Разрядность</span><b>64-бит</b></div>
|
<div class="spec"><span>Разрядность</span><b>64-бит</b></div>
|
||||||
<div class="spec"><span>Размер</span><b>~48 МБ</b></div>
|
<div class="spec"><span>Размер</span><b>~90 МБ</b></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="dl" href="ElfisaPharmacy-Setup.exe" download>
|
<a class="dl" href="ElfisaPharmacy-Setup.exe" download>
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
@ -187,14 +185,17 @@
|
|||||||
<span class="youbadge">Ваша система</span>
|
<span class="youbadge">Ваша система</span>
|
||||||
<div class="os-ico"><svg viewBox="0 0 24 24"><path d="M16.4 12.7c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-1.1 2.8-2.2.5-.8.9-1.6 1.2-2.5-.1 0-2.1-.9-2.1-3.2zM14.2 5.9c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2z"/></svg></div>
|
<div class="os-ico"><svg viewBox="0 0 24 24"><path d="M16.4 12.7c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.9-3.5.9-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.2 0 2-1.1 2.8-2.2.5-.8.9-1.6 1.2-2.5-.1 0-2.1-.9-2.1-3.2zM14.2 5.9c.6-.8 1-1.8.9-2.9-.9 0-2 .6-2.6 1.4-.6.7-1.1 1.7-.9 2.7 1 .1 2-.5 2.6-1.2z"/></svg></div>
|
||||||
<h3>macOS</h3>
|
<h3>macOS</h3>
|
||||||
<p class="os-sub">Apple Silicon и Intel · готовим сборку</p>
|
<p class="os-sub">Образ диска · Apple Silicon и Intel</p>
|
||||||
<div class="specs">
|
<div class="specs">
|
||||||
<div class="spec"><span>Формат</span><b>.dmg образ</b></div>
|
<div class="spec"><span>Формат</span><b>.dmg образ</b></div>
|
||||||
<div class="spec"><span>Система</span><b>macOS 12+</b></div>
|
<div class="spec"><span>Система</span><b>macOS 12+</b></div>
|
||||||
<div class="spec"><span>Процессор</span><b>Apple / Intel</b></div>
|
<div class="spec"><span>Процессор</span><b>Apple / Intel</b></div>
|
||||||
<div class="spec"><span>Статус</span><b>скоро</b></div>
|
<div class="spec"><span>Размер</span><b>~95 МБ</b></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="dl soon">Скоро</span>
|
<a class="dl" href="Elfisa-macOS.dmg" download>
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Скачать образ .dmg
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" data-os="linux">
|
<div class="card" data-os="linux">
|
||||||
@ -206,7 +207,7 @@
|
|||||||
<div class="spec"><span>Формат</span><b>.AppImage</b></div>
|
<div class="spec"><span>Формат</span><b>.AppImage</b></div>
|
||||||
<div class="spec"><span>Система</span><b>glibc 2.31+</b></div>
|
<div class="spec"><span>Система</span><b>glibc 2.31+</b></div>
|
||||||
<div class="spec"><span>Разрядность</span><b>x86-64</b></div>
|
<div class="spec"><span>Разрядность</span><b>x86-64</b></div>
|
||||||
<div class="spec"><span>Размер</span><b>~38 МБ</b></div>
|
<div class="spec"><span>Размер</span><b>~95 МБ</b></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="dl" href="Elfisa-Linux-x86_64.AppImage" download>
|
<a class="dl" href="Elfisa-Linux-x86_64.AppImage" download>
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 3v10m0 0 4-4m-4 4-4-4M4 17v2a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
@ -233,9 +234,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var files = { windows:"ElfisaPharmacy-Setup.exe", mac:null, linux:"Elfisa-Linux-x86_64.AppImage" };
|
var files = { windows:"ElfisaPharmacy-Setup.exe", mac:"Elfisa-macOS.dmg", linux:"Elfisa-Linux-x86_64.AppImage" };
|
||||||
var labels = { windows:"Скачать для Windows", mac:"Версия для macOS — скоро", linux:"Скачать для Linux" };
|
var labels = { windows:"Скачать для Windows", mac:"Скачать для macOS", linux:"Скачать для Linux" };
|
||||||
var metas = { windows:"Windows 10/11 · 64-бит · ~48 МБ", mac:"сборка готовится · выберите систему ниже", linux:"x86-64 · glibc 2.31+ · ~38 МБ" };
|
var metas = { windows:"Windows 10/11 · 64-бит · ~90 МБ", mac:"macOS 12+ · Apple/Intel · ~95 МБ", linux:"x86-64 · glibc 2.31+ · ~95 МБ" };
|
||||||
|
|
||||||
function detect(){
|
function detect(){
|
||||||
var ua = navigator.userAgent || "";
|
var ua = navigator.userAgent || "";
|
||||||
@ -248,12 +249,9 @@
|
|||||||
|
|
||||||
var os = detect();
|
var os = detect();
|
||||||
var hero = document.getElementById("heroBtn");
|
var hero = document.getElementById("heroBtn");
|
||||||
|
if (hero){ hero.setAttribute("href", files[os]); }
|
||||||
var lbl = document.getElementById("heroLbl"); if(lbl) lbl.textContent = labels[os];
|
var lbl = document.getElementById("heroLbl"); if(lbl) lbl.textContent = labels[os];
|
||||||
var meta = document.getElementById("heroMeta"); if(meta) meta.textContent = metas[os];
|
var meta = document.getElementById("heroMeta"); if(meta) meta.textContent = metas[os];
|
||||||
if (hero){
|
|
||||||
if (files[os]){ hero.setAttribute("href", files[os]); hero.setAttribute("download",""); }
|
|
||||||
else { hero.setAttribute("href", "#os"); hero.removeAttribute("download"); hero.classList.add("soon-hero"); }
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = document.querySelector('.card[data-os="'+os+'"]');
|
var card = document.querySelector('.card[data-os="'+os+'"]');
|
||||||
if (card){ card.classList.add("you"); }
|
if (card){ card.classList.add("you"); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user