версия 2.0.3: запоминание входа, быстрая загрузка прайса, повтор при SSL
- Session: сохранение/восстановление сессии между запусками (JWT exp-проверка) - LoadPrice: страница 20000 вместо 500 — прайс грузится одним запросом - ApiClient: повтор запроса на транзиентных сбоях TLS (поиск/загрузка) - download-страница: реальный размер, macOS помечен «скоро» Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8242184c18
commit
436bbb8bfb
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
#define MyAppName "Электронная Фармация"
|
#define MyAppName "Электронная Фармация"
|
||||||
#define MyAppBrand "ЭльФиСА"
|
#define MyAppBrand "ЭльФиСА"
|
||||||
#define MyAppVersion "2.0.2"
|
#define MyAppVersion "2.0.3"
|
||||||
#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.2</Version>
|
<Version>2.0.3</Version>
|
||||||
<AssemblyVersion>2.0.2.0</AssemblyVersion>
|
<AssemblyVersion>2.0.3.0</AssemblyVersion>
|
||||||
<FileVersion>2.0.2.0</FileVersion>
|
<FileVersion>2.0.3.0</FileVersion>
|
||||||
<Product>ЭльФиСА — Электронная Фармация</Product>
|
<Product>ЭльФиСА — Электронная Фармация</Product>
|
||||||
<Company>PharmData</Company>
|
<Company>PharmData</Company>
|
||||||
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
|
<!-- Кросс-платформенная публикация задаётся флагами CLI (RID + self-contained). -->
|
||||||
|
|||||||
@ -61,7 +61,9 @@ 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)
|
||||||
@ -77,7 +79,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 > 30000)
|
if (batch.Count < page || (total > 0 && all.Count >= total) || offset > 500000)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,12 @@ 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();
|
||||||
}
|
}
|
||||||
@ -38,7 +44,11 @@ public partial class RootViewModel : ViewModelBase
|
|||||||
catch { /* остаёмся на экране входа */ }
|
catch { /* остаёмся на экране входа */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnLoggedIn() => Current = new ShellViewModel(Logout);
|
private void OnLoggedIn()
|
||||||
|
{
|
||||||
|
Session.Current.Save(); // запомнить вход между запусками
|
||||||
|
Current = new ShellViewModel(Logout);
|
||||||
|
}
|
||||||
|
|
||||||
private void Logout()
|
private void Logout()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -37,12 +37,14 @@ 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);
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Post, Url("/auth/login"))
|
HttpResponseMessage resp;
|
||||||
|
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);
|
||||||
@ -164,10 +166,16 @@ 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 { 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); }
|
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);
|
||||||
@ -176,6 +184,21 @@ 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,6 +1,9 @@
|
|||||||
|
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();
|
||||||
@ -11,10 +14,57 @@ 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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -116,6 +116,8 @@
|
|||||||
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}
|
||||||
@ -173,7 +175,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>~90 МБ</b></div>
|
<div class="spec"><span>Размер</span><b>~48 МБ</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>
|
||||||
@ -185,17 +187,14 @@
|
|||||||
<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>~95 МБ</b></div>
|
<div class="spec"><span>Статус</span><b>скоро</b></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="dl" href="Elfisa-macOS.dmg" download>
|
<span class="dl soon">Скоро</span>
|
||||||
<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">
|
||||||
@ -207,7 +206,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>~95 МБ</b></div>
|
<div class="spec"><span>Размер</span><b>~38 МБ</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>
|
||||||
@ -234,9 +233,9 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
var files = { windows:"ElfisaPharmacy-Setup.exe", mac:"Elfisa-macOS.dmg", linux:"Elfisa-Linux-x86_64.AppImage" };
|
var files = { windows:"ElfisaPharmacy-Setup.exe", mac:null, 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-бит · ~90 МБ", mac:"macOS 12+ · Apple/Intel · ~95 МБ", linux:"x86-64 · glibc 2.31+ · ~95 МБ" };
|
var metas = { windows:"Windows 10/11 · 64-бит · ~48 МБ", mac:"сборка готовится · выберите систему ниже", linux:"x86-64 · glibc 2.31+ · ~38 МБ" };
|
||||||
|
|
||||||
function detect(){
|
function detect(){
|
||||||
var ua = navigator.userAgent || "";
|
var ua = navigator.userAgent || "";
|
||||||
@ -249,9 +248,12 @@
|
|||||||
|
|
||||||
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