diff --git a/installer/ElfisaPharmacy.iss b/installer/ElfisaPharmacy.iss index 02896a4..6bdad70 100644 --- a/installer/ElfisaPharmacy.iss +++ b/installer/ElfisaPharmacy.iss @@ -3,7 +3,7 @@ #define MyAppName "Электронная Фармация" #define MyAppBrand "ЭльФиСА" -#define MyAppVersion "2.0.2" +#define MyAppVersion "2.0.3" #define MyAppPublisher "PharmData" #define MyAppURL "https://cdn.24pharmdata.ru" #define MyAppSupportURL "https://24pharmdata.ru" diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj index 714ff39..bb8e0fe 100644 --- a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj +++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj @@ -5,9 +5,9 @@ enable app.manifest true - 2.0.2 - 2.0.2.0 - 2.0.2.0 + 2.0.3 + 2.0.3.0 + 2.0.3.0 ЭльФиСА — Электронная Фармация PharmData diff --git a/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs b/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs index aa5f9c9..c14baad 100644 --- a/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs +++ b/src/Elfisa.Avalonia/ViewModels/LoadPriceViewModel.cs @@ -61,7 +61,9 @@ public partial class LoadPriceViewModel : ViewModelBase ProgressText = "Соединение с сервером…"; var all = new List(); - const int page = 500; + // Большая страница: сервер на каждый запрос заново прогоняет тяжёлый + // сводный запрос, поэтому одна большая страница в разы быстрее десятков мелких. + const int page = 20000; int offset = 0, total = 0; while (true) @@ -77,7 +79,7 @@ public partial class LoadPriceViewModel : ViewModelBase : $"Загружено {all.Count} позиций…"; offset += batch.Count; - if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 30000) + if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 500000) break; } diff --git a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs index 6016726..770bb19 100644 --- a/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs +++ b/src/Elfisa.Avalonia/ViewModels/RootViewModel.cs @@ -15,6 +15,12 @@ public partial class RootViewModel : ViewModelBase public RootViewModel() { + // Запомнили вход в прошлый раз и токен ещё живой — сразу в программу. + if (Session.TryRestore()) + { + Current = new ShellViewModel(Logout); + return; + } Current = new LoginViewModel(OnLoggedIn); TryDevAutoLogin(); } @@ -38,7 +44,11 @@ public partial class RootViewModel : ViewModelBase catch { /* остаёмся на экране входа */ } } - private void OnLoggedIn() => Current = new ShellViewModel(Logout); + private void OnLoggedIn() + { + Session.Current.Save(); // запомнить вход между запусками + Current = new ShellViewModel(Logout); + } private void Logout() { diff --git a/src/Elfisa.Core/ApiClient.cs b/src/Elfisa.Core/ApiClient.cs index 0a8a6f8..c810fe6 100644 --- a/src/Elfisa.Core/ApiClient.cs +++ b/src/Elfisa.Core/ApiClient.cs @@ -37,12 +37,14 @@ public sealed class ApiClient public async Task 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); } + try + { + resp = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Post, Url("/auth/login")) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }).ConfigureAwait(false); + } catch (Exception ex) { throw new ApiException($"Не удалось связаться с сервером ({_baseUrl}): {ex.Message}", ex); } var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -164,10 +166,16 @@ public sealed class ApiClient private async Task 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); } + try + { + 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); } var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); @@ -176,6 +184,21 @@ public sealed class ApiClient return text; } + /// Отправляет запрос с повтором на транзиентных сбоях (холодный TLS, + /// сброс соединения, hairpin-NAT) — чтобы поиск/загрузка не падали с первого раза. + private async Task SendWithRetryAsync(Func 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() { if (!IsAuthenticated) throw new ApiException("Требуется авторизация."); diff --git a/src/Elfisa.Core/Session.cs b/src/Elfisa.Core/Session.cs index 072b419..4c09845 100644 --- a/src/Elfisa.Core/Session.cs +++ b/src/Elfisa.Core/Session.cs @@ -1,6 +1,9 @@ +using System; +using System.Text.Json; + namespace Elfisa.Core; -/// Текущая сессия пользователя (токен, роль, логин). +/// Текущая сессия пользователя (токен, роль, логин). Сохраняется между запусками. public sealed class Session { public static Session Current { get; } = new(); @@ -11,10 +14,57 @@ public sealed class Session public bool IsAuthenticated => !string.IsNullOrWhiteSpace(Token); + /// Сохранить сессию в настройки (чтобы не логиниться каждый раз). + public void Save() + { + SettingsStore.Current.Token = Token; + SettingsStore.Current.Role = Role; + SettingsStore.Current.Username = Username; + SettingsStore.Save(); + } + + /// Восстановить сессию из настроек, если токен есть и не истёк. + 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() { Token = null; Role = null; Username = null; + SettingsStore.Current.Token = null; + SettingsStore.Current.Role = null; + SettingsStore.Current.Username = null; + SettingsStore.Save(); + } + + /// Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту. + 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; } } } diff --git a/web/download/index.html b/web/download/index.html index ff9eec7..a8516bc 100644 --- a/web/download/index.html +++ b/web/download/index.html @@ -116,6 +116,8 @@ 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 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 ---- */ .feats{padding:30px 0 8px; display:grid; grid-template-columns:repeat(3,1fr); gap:14px} @@ -173,7 +175,7 @@
Формат.exe установщик
СистемаWindows 10 / 11
Разрядность64-бит
-
Размер~90 МБ
+
Размер~48 МБ
@@ -185,17 +187,14 @@ Ваша система

macOS

-

Образ диска · Apple Silicon и Intel

+

Apple Silicon и Intel · готовим сборку

Формат.dmg образ
СистемаmacOS 12+
ПроцессорApple / Intel
-
Размер~95 МБ
+
Статусскоро
-
- - Скачать образ .dmg - + Скоро
@@ -207,7 +206,7 @@
Формат.AppImage
Системаglibc 2.31+
Разрядностьx86-64
-
Размер~95 МБ
+
Размер~38 МБ
@@ -234,9 +233,9 @@