From 6ae79e37fbbeb1208575b8232fa43390f3d2d615 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 14:35:18 +0500 Subject: [PATCH] =?UTF-8?q?feat(pos-ui):=20=D0=B2=D1=91=D1=80=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BA=D0=B0=D1=81=D1=81=D1=8B=201:1=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=8D=D1=82=D0=B0=D0=BB=D0=BE=D0=BD-=D0=BC=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенёс макет Mac-Claude (docs/pos-design/pos-mockup.html) в Avalonia по размерам, а не на глаз: - Оплата: модалка 880px поверх затемнённого экрана продажи (как в макете); сегменты Нал/Карта/Смешанная с векторными иконками (активный зелёный — иконка белая через стиль Button.seg.on Path); быстрые суммы 3×2 (без сдачи/4000/5000/10000/20000/своя сумма); клавиатура 3×4 по 72px. - Продажа: исправлена подсветка выбранной строки — локальные Background/IsVisible перекрывали сеттеры стиля :selected; теперь зелёный акцент слева + светло-зелёный фон работают. Бейдж «Отложенные · N». Чип связи «На связи · очередь пуста». - Демо: --demo-pay открывает модалку оплаты для скриншота на Windows; PayScrim в скриншотере рендерит оплату поверх реальной оболочки продажи. - Инструмент сверки: CompareRunner (--compare, SkiaSharp) складывает «макет | касса» рядом → docs/pos-design/compare-*.png. Скорость не ухудшена (старт 74мс, скан 13.7мс). --uitest OK, 82 unit зелёные. Co-Authored-By: Claude Opus 4.8 --- src/food-market.pos.core/Models/PosModels.cs | 2 + src/food-market.pos/App.axaml.cs | 11 +- src/food-market.pos/Program.cs | 4 +- src/food-market.pos/Styles/PosStyles.axaml | 57 ++++- src/food-market.pos/Styles/Tokens.axaml | 18 +- src/food-market.pos/Tooling/CompareRunner.cs | 72 ++++++ .../Tooling/ScreenshotRunner.cs | 23 +- src/food-market.pos/ViewModels/ViewModels.cs | 29 ++- src/food-market.pos/Views/MainWindow.axaml | 82 +++--- src/food-market.pos/Views/PaymentView.axaml | 125 ++++++---- src/food-market.pos/Views/SaleView.axaml | 235 +++++++++++------- 11 files changed, 448 insertions(+), 210 deletions(-) create mode 100644 src/food-market.pos/Tooling/CompareRunner.cs diff --git a/src/food-market.pos.core/Models/PosModels.cs b/src/food-market.pos.core/Models/PosModels.cs index 726df57..59b8fb7 100644 --- a/src/food-market.pos.core/Models/PosModels.cs +++ b/src/food-market.pos.core/Models/PosModels.cs @@ -20,6 +20,8 @@ public sealed class PosProduct public bool IsArchived { get; init; } public bool IsFavorite { get; init; } public bool IsWeighed => Packaging == PosPackaging.Weight; + /// Цена для плитки избранного: «690 ₸/кг» для весового, «280 ₸» для штучного. + public string PriceText => IsWeighed ? $"{Price:N0} ₸/{UnitCode}" : $"{Price:N0} ₸"; } /// Строка чека. Количество дробное (весовой/разлив). Скидка — абсолютная diff --git a/src/food-market.pos/App.axaml.cs b/src/food-market.pos/App.axaml.cs index ea0243e..9fa1f7c 100644 --- a/src/food-market.pos/App.axaml.cs +++ b/src/food-market.pos/App.axaml.cs @@ -13,6 +13,9 @@ public partial class App : Application /// сервера — для скриншота реального окна на Windows. public static bool DemoMode { get; set; } + /// Демо с открытой модалкой оплаты (--demo-pay) — для скриншота оплаты на Windows. + public static bool DemoPay { get; set; } + public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() @@ -23,9 +26,15 @@ public override void OnFrameworkInitializationCompleted() { // Демо: окно с примерным чеком, без AppServices/логина; развёрнуто // на весь экран — для скриншота реального окна на Windows. + var demoVm = new MainWindowViewModel(); + if (DemoPay) + { + demoVm.Payment = PaymentViewModel.SampleCash(); + demoVm.IsPaymentOpen = true; + } desktop.MainWindow = new MainWindow { - DataContext = new MainWindowViewModel(), + DataContext = demoVm, WindowState = Avalonia.Controls.WindowState.Maximized, }; } diff --git a/src/food-market.pos/Program.cs b/src/food-market.pos/Program.cs index 75750df..ecfddc7 100644 --- a/src/food-market.pos/Program.cs +++ b/src/food-market.pos/Program.cs @@ -15,10 +15,12 @@ public static int Main(string[] args) // --uitest : headless UI-автотест экрана продажи (Avalonia.Headless, для CI/xvfb). if (args.Contains("--smoke")) return SmokeRunner.Run(args); if (args.Contains("--screenshots")) return ScreenshotRunner.Run(args); + if (args.Contains("--compare")) return CompareRunner.Run(args); if (args.Contains("--uitest")) return UiTestRunner.Run(args); // --demo : показать реальное окно с примерами данных (для скриншота на Windows). - if (args.Contains("--demo")) App.DemoMode = true; + if (args.Contains("--demo") || args.Contains("--demo-pay")) App.DemoMode = true; + if (args.Contains("--demo-pay")) App.DemoPay = true; BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); return 0; diff --git a/src/food-market.pos/Styles/PosStyles.axaml b/src/food-market.pos/Styles/PosStyles.axaml index d242085..26cf749 100644 --- a/src/food-market.pos/Styles/PosStyles.axaml +++ b/src/food-market.pos/Styles/PosStyles.axaml @@ -16,20 +16,21 @@ - - + + - + + + + + + + + + + + + + + + diff --git a/src/food-market.pos/Styles/Tokens.axaml b/src/food-market.pos/Styles/Tokens.axaml index f85d281..f0a8d15 100644 --- a/src/food-market.pos/Styles/Tokens.axaml +++ b/src/food-market.pos/Styles/Tokens.axaml @@ -7,19 +7,25 @@ + + + + - + + + - + @@ -27,19 +33,25 @@ + + + + + + - + diff --git a/src/food-market.pos/Tooling/CompareRunner.cs b/src/food-market.pos/Tooling/CompareRunner.cs new file mode 100644 index 0000000..760e032 --- /dev/null +++ b/src/food-market.pos/Tooling/CompareRunner.cs @@ -0,0 +1,72 @@ +using SkiaSharp; + +namespace foodmarket.Pos.Tooling; + +/// Складывает «макет | касса» рядом в одно изображение для поэлементной +/// сверки вёрстки. Использует уже отрисованные PNG: docs/pos-design/mockup-*.png +/// (эталон) и docs/pos-screenshots/*.png (наш рендер). +/// Запуск: dotnet run -- --compare +public static class CompareRunner +{ + private sealed record Pair(string Name, string Mockup, string Shot); + + public static int Run(string[] args) + { + var root = FindRepoRoot(); + var design = Path.Combine(root, "docs", "pos-design"); + var shots = Path.Combine(root, "docs", "pos-screenshots"); + Directory.CreateDirectory(design); + + var pairs = new[] + { + new Pair("compare-sale", Path.Combine(design, "mockup-sale-1920x1080.png"), Path.Combine(shots, "04-sale-items-1920x1080.png")), + new Pair("compare-payment", Path.Combine(design, "mockup-payment-1920x1080.png"), Path.Combine(shots, "05-payment-cash-1920x1080.png")), + }; + + foreach (var p in pairs) + { + if (!File.Exists(p.Mockup)) { Console.WriteLine($"[skip] нет макета {p.Mockup}"); continue; } + if (!File.Exists(p.Shot)) { Console.WriteLine($"[skip] нет рендера {p.Shot}"); continue; } + + using var left = SKBitmap.Decode(p.Mockup); + using var right = SKBitmap.Decode(p.Shot); + + const int gap = 24; + const int header = 56; + int h = Math.Max(left.Height, right.Height); + int w = left.Width + right.Width + gap; + + var outPath = Path.Combine(design, p.Name + ".png"); + using var surface = SKSurface.Create(new SKImageInfo(w, h + header)); + var c = surface.Canvas; + c.Clear(new SKColor(0xB4, 0xB4, 0xB4)); + + using var font = new SKPaint { Color = SKColors.White, TextSize = 26, IsAntialias = true, FakeBoldText = true }; + c.DrawText("МАКЕТ (эталон)", 16, 38, font); + c.DrawText("КАССА (Avalonia)", left.Width + gap + 16, 38, font); + + c.DrawBitmap(left, 0, header); + c.DrawBitmap(right, left.Width + gap, header); + + // вертикальный разделитель + using var line = new SKPaint { Color = new SKColor(0x30, 0x30, 0x30), StrokeWidth = 2 }; + c.DrawLine(left.Width + gap / 2f, header, left.Width + gap / 2f, h + header, line); + + using var img = surface.Snapshot(); + using var data = img.Encode(SKEncodedImageFormat.Png, 90); + using var fs = File.OpenWrite(outPath); + data.SaveTo(fs); + Console.WriteLine($"[compare] {outPath} ({w}×{h + header})"); + } + + return 0; + } + + private static string FindRepoRoot() + { + var dir = AppContext.BaseDirectory; + while (dir is not null && !Directory.Exists(Path.Combine(dir, ".git"))) + dir = Directory.GetParent(dir)?.FullName; + return dir ?? Directory.GetCurrentDirectory(); + } +} diff --git a/src/food-market.pos/Tooling/ScreenshotRunner.cs b/src/food-market.pos/Tooling/ScreenshotRunner.cs index fcb1d7d..9039410 100644 --- a/src/food-market.pos/Tooling/ScreenshotRunner.cs +++ b/src/food-market.pos/Tooling/ScreenshotRunner.cs @@ -78,18 +78,15 @@ private static Control ShellWith(SaleViewModel sale) vm.CurrentPage = sale; return new MainWindow { DataContext = vm }; } - /// Модальная оплата на затемнённой подложке (как в оболочке): - /// светлый фон → полупрозрачная тёмная подложка → белое модальное окно. + /// Модальная оплата поверх затемнённого экрана продажи — как в оболочке + /// и как в макете: чек виден под полупрозрачной тёмной подложкой. private static Control PayScrim(PaymentViewModel pay) - => new Border - { - Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#F4F6F5")), - Child = new Border - { - Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse("#660F1F17")), - Child = new PaymentView { DataContext = pay }, - }, - }; + { + var vm = new MainWindowViewModel(); // в CurrentPage уже лежит пример продажи + vm.Payment = pay; + vm.IsPaymentOpen = true; + return new MainWindow { DataContext = vm }; + } private static Control ShellOffline() { @@ -160,7 +157,9 @@ private static void WriteGallery(string dir, List<(string file, string caption)> foreach (var w in wins) { var rel = "windows/" + Path.GetFileName(w); - sb.Append($"
Windows 11 — экран продажи (реальное окно)
"); + var name = Path.GetFileNameWithoutExtension(w); + var label = name.Contains("pay", StringComparison.OrdinalIgnoreCase) ? "экран оплаты" : "экран продажи"; + sb.Append($"
Windows 11 — {label} (реальное окно)
"); } sb.Append(""); } diff --git a/src/food-market.pos/ViewModels/ViewModels.cs b/src/food-market.pos/ViewModels/ViewModels.cs index 11c9641..8d0920a 100644 --- a/src/food-market.pos/ViewModels/ViewModels.cs +++ b/src/food-market.pos/ViewModels/ViewModels.cs @@ -25,7 +25,8 @@ public partial class MainWindowViewModel : ViewModelBase [ObservableProperty] private string _cashierName = "—"; [ObservableProperty] private string _shiftNumber = "смена не открыта"; [ObservableProperty] private bool _isShiftOpen; - [ObservableProperty] private string _pointName = "Магазин"; + [ObservableProperty] private string _storeName = "Магазин"; + [ObservableProperty] private string _pointName = "Касса"; [ObservableProperty] private bool _isOnline = true; [ObservableProperty] private int _queueCount; [ObservableProperty] private PaymentViewModel? _payment; @@ -34,14 +35,14 @@ public partial class MainWindowViewModel : ViewModelBase public string NetBadge => IsOnline ? "На связи" : "Нет связи"; public string ShiftBadge => IsShiftOpen ? $"Смена {ShiftNumber}" : "Смена закрыта"; - public string QueueBadge => QueueCount > 0 ? $"· {QueueCount} в очереди" : ""; + public string QueueBadge => QueueCount > 0 ? $"· {QueueCount} в очереди" : "· очередь пуста"; partial void OnIsOnlineChanged(bool value) { OnPropertyChanged(nameof(NetBadge)); } partial void OnQueueCountChanged(int value) { OnPropertyChanged(nameof(QueueBadge)); } partial void OnIsShiftOpenChanged(bool value) => OnPropertyChanged(nameof(ShiftBadge)); partial void OnShiftNumberChanged(string value) => OnPropertyChanged(nameof(ShiftBadge)); /// Конструктор для дизайнера/скриншотов (демо-данные). - public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000001"; IsShiftOpen = true; PointName = "Магазин №1 · ул. Абая 10"; } + public MainWindowViewModel() { CurrentPage = SaleViewModel.Sample(); CashierName = "Пётр Кассиров"; ShiftNumber = "С-2026-000012 открыта с 09:00"; IsShiftOpen = true; StoreName = "Демо-магазин"; PointName = "Касса 1"; } public MainWindowViewModel(AppServices svc) { _svc = svc; if (svc.Session is not null) StartSession(); else ShowLogin(); } @@ -363,9 +364,10 @@ public string QtyText } } public bool HasDiscount => Discount > 0m; - /// Пояснение скидки в строке, чтобы цена×кол-во ≠ сумма не выглядело - /// ошибкой: «1 560 → 1 500 ₸, скидка 60». - public string DiscountText => $"{Gross:N0} → {LineTotal:N0} ₸, скидка {Discount:N0}"; + /// Подпись цены за единицу: «280 ₸ за шт», «690 ₸ за кг». + public string UnitPriceText => $"{Price:N0} ₸ за {Unit}"; + /// Строка со скидкой: «520 ₸ за шт · скидка 60 ₸ (1 560 → 1 500 ₸)». + public string DiscountText => $"{Price:N0} ₸ за {Unit} · скидка {Discount:N0} ₸ ({Gross:N0} → {LineTotal:N0} ₸)"; partial void OnQuantityChanged(decimal value) { OnPropertyChanged(nameof(LineTotal)); OnPropertyChanged(nameof(QtyText)); OnPropertyChanged(nameof(Gross)); OnPropertyChanged(nameof(DiscountText)); } partial void OnDiscountChanged(decimal value) @@ -396,6 +398,10 @@ public partial class SaleViewModel : ViewModelBase [ObservableProperty] private SaleLineVm? _selectedLine; public bool IsCartEmpty => ItemCount == 0; public bool HasAnyDiscount => Discount > 0m; + // Блок «Последний чек / Чеков за смену» (информационный, заполняет панель). + [ObservableProperty] private string _lastReceiptNumber = "—"; + [ObservableProperty] private string _lastReceiptInfo = "—"; + [ObservableProperty] private int _receiptsInShift; partial void OnItemCountChanged(int value) => OnPropertyChanged(nameof(IsCartEmpty)); partial void OnDiscountChanged(decimal value) => OnPropertyChanged(nameof(HasAnyDiscount)); @@ -520,7 +526,9 @@ public async Task CommitPaymentAsync(decimal cash, decimal card, decimal change) [RelayCommand] private void Clear() { _cart.Clear(); Lines.Clear(); SelectedLine = null; Recalc(); Message = "Чек очищен"; } - public int ParkedCount => _parked.Count; + private int _demoParked; // только для скриншотов/демо (Sample) + public int ParkedCount => _parked.Count + _demoParked; + public bool HasParked => ParkedCount > 0; /// Отложить текущий чек и начать новый (обслужить другого покупателя). [RelayCommand] @@ -567,12 +575,15 @@ public static SaleViewModel Sample() vm.Total = decimal.Round(vm.Subtotal - vm.Discount, 2); vm.ItemCount = vm.Lines.Count; vm.Message = "Чек готов к оплате — F12 Оплата"; + vm.SelectedLine = vm.Lines[3]; // выбранная строка (Сахар) — зелёный акцент + vm.LastReceiptNumber = "№ 000148"; vm.LastReceiptInfo = "1 250 ₸ · нал"; vm.ReceiptsInShift = 47; + vm._demoParked = 2; // в демо — 2 отложенных чека (бейдж на кнопке «Отложенные») PosProduct Fav(string n, string unit, decimal price, bool weighed) => new() { Id = Guid.NewGuid(), Name = n, Barcodes = Array.Empty(), UnitCode = unit, Packaging = weighed ? PosPackaging.Weight : PosPackaging.Piece, VatPercent = 12m, VatEnabled = true, Price = price, IsFavorite = true }; foreach (var f in new[] { - Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2.5%", "шт", 450m, false), + Fav("Хлеб «Семейный»", "шт", 280m, false), Fav("Молоко 2,5%", "шт", 450m, false), Fav("Яблоки (вес)", "кг", 690m, true), Fav("Картофель (вес)", "кг", 280m, true), - Fav("Пакет-майка", "шт", 20m, false), Fav("Вода 0,5л", "шт", 150m, false) }) + Fav("Пакет-майка", "шт", 20m, false), Fav("Вода 0,5 л", "шт", 150m, false) }) vm.Favorites.Add(f); return vm; } diff --git a/src/food-market.pos/Views/MainWindow.axaml b/src/food-market.pos/Views/MainWindow.axaml index f0747be..802f96a 100644 --- a/src/food-market.pos/Views/MainWindow.axaml +++ b/src/food-market.pos/Views/MainWindow.axaml @@ -7,63 +7,61 @@ Background="{DynamicResource PosBg}" Title="Касса food-market" WindowStartupLocation="CenterScreen"> - + - - - - - - - - - - - + + + + + + + + + + + + + + + - + - + + + + - - + + + - - - - - - - - - + - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/src/food-market.pos/Views/PaymentView.axaml b/src/food-market.pos/Views/PaymentView.axaml index f1b657e..bd099b6 100644 --- a/src/food-market.pos/Views/PaymentView.axaml +++ b/src/food-market.pos/Views/PaymentView.axaml @@ -3,76 +3,105 @@ xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels" x:Class="foodmarket.Pos.Views.PaymentView" x:DataType="vm:PaymentViewModel"> - - + + + - - - - + + + + - - + + - + + + - - - - - + + + + + + + + - - - - - - - - + + diff --git a/src/food-market.pos/Views/SaleView.axaml b/src/food-market.pos/Views/SaleView.axaml index 721a2fa..59d5253 100644 --- a/src/food-market.pos/Views/SaleView.axaml +++ b/src/food-market.pos/Views/SaleView.axaml @@ -3,93 +3,107 @@ xmlns:vm="clr-namespace:foodmarket.Pos.ViewModels" x:Class="foodmarket.Pos.Views.SaleView" x:DataType="vm:SaleViewModel"> - + - - + + - - - + + + + - - - - - - - - - - - - + + + + + + + + + + + - + - - - - - + + + + + + + - - + + Background="Transparent" BorderThickness="0" Padding="0" IsVisible="{Binding !IsCartEmpty}"> - + + + + + + - - - - - - + + + + + + + - - - @@ -98,40 +112,85 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + - - - - - - -