diff --git a/AuroraDesk.Infrastructure/Services/NavigationService.cs b/AuroraDesk.Infrastructure/Services/NavigationService.cs index a0071b0..da8fc4c 100644 --- a/AuroraDesk.Infrastructure/Services/NavigationService.cs +++ b/AuroraDesk.Infrastructure/Services/NavigationService.cs @@ -141,6 +141,20 @@ public class NavigationService : INavigationService IconType = IconType.Photo, ViewModel = _pageViewModelFactory.CreatePageViewModel("image-gallery", screen) }, + new NavigationItem + { + Id = "file-explorer", + Title = "文件浏览", + IconType = IconType.Folder, + ViewModel = _pageViewModelFactory.CreatePageViewModel("file-explorer", screen) + }, + new NavigationItem + { + Id = "animation-breathe", + Title = "动画演示", + IconType = IconType.PlayCircle, + ViewModel = _pageViewModelFactory.CreatePageViewModel("animation-breathe", screen) + }, new NavigationItem { Id = "udp-tools", @@ -165,6 +179,22 @@ public class NavigationService : INavigationService } }, new NavigationItem + { + Id = "tcp-tools", + Title = "TCP 工具", + IconType = IconType.GlobeAlt, + Children = new ObservableCollection + { + new NavigationItem + { + Id = "tcp-client", + Title = "TCP 客户端", + IconType = IconType.Link, + ViewModel = _pageViewModelFactory.CreatePageViewModel("tcp-client", screen) + } + } + }, + new NavigationItem { Id = "ssh-tools", Title = "SSH 工具", diff --git a/AuroraDesk.Presentation/AuroraDesk.Presentation.csproj b/AuroraDesk.Presentation/AuroraDesk.Presentation.csproj index 3cbe029..da94768 100644 --- a/AuroraDesk.Presentation/AuroraDesk.Presentation.csproj +++ b/AuroraDesk.Presentation/AuroraDesk.Presentation.csproj @@ -9,6 +9,7 @@ + @@ -20,6 +21,7 @@ + diff --git a/AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs b/AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs index a8fa276..cb28168 100644 --- a/AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs +++ b/AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs @@ -45,8 +45,10 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/AuroraDesk.Presentation/Resources/Animations/breathe.json b/AuroraDesk.Presentation/Resources/Animations/breathe.json new file mode 100644 index 0000000..d3eb1ec --- /dev/null +++ b/AuroraDesk.Presentation/Resources/Animations/breathe.json @@ -0,0 +1,359 @@ +{ + "v": "5.12.2", + "fr": 25, + "ip": 0, + "op": 75, +"w": 1200, + "h": 280, + "nm": "Breathe", + "ddd": 0, + "assets": [], + "fonts": { + "list": [ + { + "origin": 0, + "fPath": "", + "fClass": "", + "fFamily": "Inter", + "fWeight": "", + "fStyle": "Regular", + "fName": "Inter", + "ascent": 66.69921875 + } + ] + }, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 5, + "nm": "AVALONIA 2", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 60, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ 600, 140, 0 ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ 294.336, -61.92, 0 ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ 85, 85, 100 ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "t": { + "d": { + "k": [ + { + "s": { + "s": 150, + "f": "Inter", + "t": "AVALONIA", + "ca": 0, + "j": 2, + "tr": 0, + "lh": 180.0, + "ls": 0, + "fc": [ 0.227, 0.545, 0.976 ] + }, + "t": 0 + } + ] + }, + "p": {}, + "m": { + "g": 1, + "a": { + "a": 0, + "k": [ 0, 0 ], + "ix": 2 + } + }, + "a": [] + }, + "ip": 0, + "op": 75.0750750750751, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 5, + "nm": "AVALONIA", + "sr": 1, + "ks": { + "o": { + "a": 1, + "k": [ + { + "i": { + "x": [ 0.667 ], + "y": [ 1 ] + }, + "o": { + "x": [ 0.333 ], + "y": [ 0 ] + }, + "t": -0.537, + "s": [ 100 ] + }, + { + "i": { + "x": [ 0.667 ], + "y": [ 1 ] + }, + "o": { + "x": [ 0.333 ], + "y": [ 0 ] + }, + "t": 37, + "s": [ 50 ] + }, + { + "t": 74.5380859375, + "s": [ 100 ] + } + ], + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ 600, 140, 0 ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ 294.336, -61.92, 0 ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ 85, 85, 100 ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "ef": [ + { + "ty": 25, + "nm": "投影", + "np": 8, + "mn": "ADBE Drop Shadow", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 2, + "nm": "阴影颜色", + "mn": "ADBE Drop Shadow-0001", + "ix": 1, + "v": { + "a": 0, + "k": [ 0, 0.627451002598, 0.627451002598, 1 ], + "ix": 1 + } + }, + { + "ty": 0, + "nm": "Opacity", + "mn": "ADBE Drop Shadow-0002", + "ix": 2, + "v": { + "a": 0, + "k": 127.5, + "ix": 2 + } + }, + { + "ty": 0, + "nm": "方向", + "mn": "ADBE Drop Shadow-0003", + "ix": 3, + "v": { + "a": 0, + "k": 135, + "ix": 3 + } + }, + { + "ty": 0, + "nm": "距离", + "mn": "ADBE Drop Shadow-0004", + "ix": 4, + "v": { + "a": 0, + "k": 5, + "ix": 4 + } + }, + { + "ty": 0, + "nm": "Softness", + "mn": "ADBE Drop Shadow-0005", + "ix": 5, + "v": { + "a": 0, + "k": 30, + "ix": 5 + } + }, + { + "ty": 7, + "nm": "Shadow Only", + "mn": "ADBE Drop Shadow-0006", + "ix": 6, + "v": { + "a": 0, + "k": 0, + "ix": 6 + } + } + ] + }, + { + "ty": 25, + "nm": "投影 2", + "np": 8, + "mn": "ADBE Drop Shadow", + "ix": 2, + "en": 1, + "ef": [ + { + "ty": 2, + "nm": "阴影颜色", + "mn": "ADBE Drop Shadow-0001", + "ix": 1, + "v": { + "a": 0, + "k": [ 0, 0.627451002598, 0.627451002598, 1 ], + "ix": 1 + } + }, + { + "ty": 0, + "nm": "Opacity", + "mn": "ADBE Drop Shadow-0002", + "ix": 2, + "v": { + "a": 0, + "k": 255, + "ix": 2 + } + }, + { + "ty": 0, + "nm": "方向", + "mn": "ADBE Drop Shadow-0003", + "ix": 3, + "v": { + "a": 0, + "k": 135, + "ix": 3 + } + }, + { + "ty": 0, + "nm": "距离", + "mn": "ADBE Drop Shadow-0004", + "ix": 4, + "v": { + "a": 0, + "k": 10, + "ix": 4 + } + }, + { + "ty": 0, + "nm": "Softness", + "mn": "ADBE Drop Shadow-0005", + "ix": 5, + "v": { + "a": 0, + "k": 80, + "ix": 5 + } + }, + { + "ty": 7, + "nm": "Shadow Only", + "mn": "ADBE Drop Shadow-0006", + "ix": 6, + "v": { + "a": 0, + "k": 0, + "ix": 6 + } + } + ] + } + ], + "t": { + "d": { + "k": [ + { + "s": { + "s": 150, + "f": "Inter", + "t": "AVALONIA", + "ca": 0, + "j": 2, + "tr": 0, + "lh": 180.0, + "ls": 0, + "fc": [ 0.227, 0.545, 0.976 ] + }, + "t": 0 + } + ] + }, + "p": {}, + "m": { + "g": 1, + "a": { + "a": 0, + "k": [ 0, 0 ], + "ix": 2 + } + }, + "a": [] + }, + "ip": 0, + "op": 75.0750750750751, + "st": 0, + "ct": 1, + "bm": 0 + } + ], + "markers": [], + "props": {} +} + diff --git a/AuroraDesk.Presentation/Resources/Animations/breatheV2.json b/AuroraDesk.Presentation/Resources/Animations/breatheV2.json new file mode 100644 index 0000000..9cb1389 --- /dev/null +++ b/AuroraDesk.Presentation/Resources/Animations/breatheV2.json @@ -0,0 +1,366 @@ +{ + "v": "5.12.2", + "fr": 25, + "ip": 0, + "op": 75, +"w": 960, + "h": 280, + "nm": "Breathe", + "ddd": 0, + "assets": [], + "fonts": { + "list": [ + { + "origin": 0, + "fPath": "", + "fClass": "", + "fFamily": "Inter", + "fWeight": "", + "fStyle": "Regular", + "fName": "Inter", + "ascent": 66.69921875 + } + ] + }, + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 5, + "nm": "WELCOME BACK 2", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 60, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ 480, 140, 0 ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ 294.336, -61.92, 0 ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ 62, 62, 100 ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "t": { + "d": { + "k": [ + { + "s": { + "s": 100, + "f": "Inter", + "t": "WELCOME BACK", + "ca": 0, + "j": 2, + "tr": -4, + "lh": 130.0, + "ls": -3, + "fc": [ 0.086, 0.95, 1 ], + "sc": [ 0, 0.55, 1 ], + "sw": 4, + "lc": 2, + "lj": 2 + }, + "t": 0 + } + ] + }, + "p": {}, + "m": { + "g": 1, + "a": { + "a": 0, + "k": [ 0, 0 ], + "ix": 2 + } + }, + "a": [] + }, + "ip": 0, + "op": 75.0750750750751, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 5, + "nm": "WELCOME BACK", + "sr": 1, + "ks": { + "o": { + "a": 1, + "k": [ + { + "i": { + "x": [ 0.667 ], + "y": [ 1 ] + }, + "o": { + "x": [ 0.333 ], + "y": [ 0 ] + }, + "t": -0.537, + "s": [ 100 ] + }, + { + "i": { + "x": [ 0.667 ], + "y": [ 1 ] + }, + "o": { + "x": [ 0.333 ], + "y": [ 0 ] + }, + "t": 37, + "s": [ 50 ] + }, + { + "t": 74.5380859375, + "s": [ 100 ] + } + ], + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ 480, 140, 0 ], + "ix": 2, + "l": 2 + }, + "a": { + "a": 0, + "k": [ 294.336, -61.92, 0 ], + "ix": 1, + "l": 2 + }, + "s": { + "a": 0, + "k": [ 62, 62, 100 ], + "ix": 6, + "l": 2 + } + }, + "ao": 0, + "ef": [ + { + "ty": 25, + "nm": "投影", + "np": 8, + "mn": "ADBE Drop Shadow", + "ix": 1, + "en": 1, + "ef": [ + { + "ty": 2, + "nm": "阴影颜色", + "mn": "ADBE Drop Shadow-0001", + "ix": 1, + "v": { + "a": 0, + "k": [ 0, 0.870588, 1, 1 ], + "ix": 1 + } + }, + { + "ty": 0, + "nm": "不透明度", + "mn": "ADBE Drop Shadow-0002", + "ix": 2, + "v": { + "a": 0, + "k": 127.5, + "ix": 2 + } + }, + { + "ty": 0, + "nm": "方向", + "mn": "ADBE Drop Shadow-0003", + "ix": 3, + "v": { + "a": 0, + "k": 135, + "ix": 3 + } + }, + { + "ty": 0, + "nm": "距离", + "mn": "ADBE Drop Shadow-0004", + "ix": 4, + "v": { + "a": 0, + "k": 8, + "ix": 4 + } + }, + { + "ty": 0, + "nm": "柔和度", + "mn": "ADBE Drop Shadow-0005", + "ix": 5, + "v": { + "a": 0, + "k": 48, + "ix": 5 + } + }, + { + "ty": 7, + "nm": "仅阴影", + "mn": "ADBE Drop Shadow-0006", + "ix": 6, + "v": { + "a": 0, + "k": 0, + "ix": 6 + } + } + ] + }, + { + "ty": 25, + "nm": "投影 2", + "np": 8, + "mn": "ADBE Drop Shadow", + "ix": 2, + "en": 1, + "ef": [ + { + "ty": 2, + "nm": "阴影颜色", + "mn": "ADBE Drop Shadow-0001", + "ix": 1, + "v": { + "a": 0, + "k": [ 0, 0.870588, 1, 1 ], + "ix": 1 + } + }, + { + "ty": 0, + "nm": "不透明度", + "mn": "ADBE Drop Shadow-0002", + "ix": 2, + "v": { + "a": 0, + "k": 255, + "ix": 2 + } + }, + { + "ty": 0, + "nm": "方向", + "mn": "ADBE Drop Shadow-0003", + "ix": 3, + "v": { + "a": 0, + "k": 135, + "ix": 3 + } + }, + { + "ty": 0, + "nm": "距离", + "mn": "ADBE Drop Shadow-0004", + "ix": 4, + "v": { + "a": 0, + "k": 16, + "ix": 4 + } + }, + { + "ty": 0, + "nm": "柔和度", + "mn": "ADBE Drop Shadow-0005", + "ix": 5, + "v": { + "a": 0, + "k": 120, + "ix": 5 + } + }, + { + "ty": 7, + "nm": "仅阴影", + "mn": "ADBE Drop Shadow-0006", + "ix": 6, + "v": { + "a": 0, + "k": 0, + "ix": 6 + } + } + ] + } + ], + "t": { + "d": { + "k": [ + { + "s": { + "s": 100, + "f": "Inter", + "t": "WELCOME BACK", + "ca": 0, + "j": 2, + "tr": -4, + "lh": 130.0, + "ls": -3, + "fc": [ 0.086, 0.95, 1 ], + "sc": [ 0, 0.55, 1 ], + "sw": 4, + "lc": 2, + "lj": 2 + }, + "t": 0 + } + ] + }, + "p": {}, + "m": { + "g": 1, + "a": { + "a": 0, + "k": [ 0, 0 ], + "ix": 2 + } + }, + "a": [] + }, + "ip": 0, + "op": 75.0750750750751, + "st": 0, + "ct": 1, + "bm": 0 + } + ], + "markers": [], + "props": {} +} \ No newline at end of file diff --git a/AuroraDesk.Presentation/Resources/Animations/breathe_V3.json b/AuroraDesk.Presentation/Resources/Animations/breathe_V3.json new file mode 100644 index 0000000..5d3cf34 --- /dev/null +++ b/AuroraDesk.Presentation/Resources/Animations/breathe_V3.json @@ -0,0 +1,84 @@ +{ + "v": "5.7.1", + "fr": 30, + "ip": 0, + "op": 120, + "w": 200, + "h": 200, + "nm": "Breathe Animation", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 0, + "ty": 4, + "nm": "Breathe Circle", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100 }, + "r": { "a": 0, "k": 0 }, + "p": { "a": 0, "k": [100, 100, 0] }, + "a": { "a": 0, "k": [0, 0, 0] }, + "s": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 0, + "s": [90, 90, 100], + "e": [60, 60, 100] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 60, + "s": [110, 110, 100], + "e": [120, 120, 100] + }, + { + "t": 120 + } + ] + } + }, + "ao": 0, + "shapes": [ + { + "ty": "el", + "d": 1, + "s": { "a": 0, "k": [100, 100] }, + "p": { "a": 0, "k": [0, 0] }, + "nm": "Ellipse Path 1", + "mn": "ADBE Vector Shape - Ellipse", + "hd": false + }, + { + "ty": "fl", + "c": { "a": 0, "k": [0.2, 0.6, 1, 1] }, + "o": { "a": 0, "k": 100 }, + "r": 1, + "bm": 0, + "nm": "Fill 1" + }, + { + "ty": "st", + "c": { "a": 0, "k": [0.2, 0.6, 1, 1] }, + "o": { "a": 0, "k": 100 }, + "w": { "a": 0, "k": 5 }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1" + } + ], + "ip": 0, + "op": 120, + "st": 0, + "bm": 0 + } + ], + "markers": [] +} \ No newline at end of file diff --git a/AuroraDesk.Presentation/Services/PageViewModelFactory.cs b/AuroraDesk.Presentation/Services/PageViewModelFactory.cs index f8d832c..cb03a4e 100644 --- a/AuroraDesk.Presentation/Services/PageViewModelFactory.cs +++ b/AuroraDesk.Presentation/Services/PageViewModelFactory.cs @@ -55,11 +55,14 @@ public class PageViewModelFactory : IPageViewModelFactory "icons" => CreateIconsPageViewModel(screen), "editor" => CreateEditorPageViewModel(screen), "image-gallery" => CreateImageGalleryPageViewModel(screen), + "file-explorer" => CreateFileExplorerPageViewModel(screen), "udp-client" => CreateUdpClientPageViewModel(screen), "udp-server" => CreateUdpServerPageViewModel(screen), + "tcp-client" => CreateTcpClientPageViewModel(screen), "node-canvas" => CreateNodeCanvasPageViewModel(screen), "plink-manager" => CreatePlinkPageViewModel(screen), "ssh-manager" => CreateSshPageViewModel(screen), + "animation-breathe" => CreatePageViewModel(screen), _ => throw new ArgumentException($"Unknown page: {pageId}", nameof(pageId)) }; } @@ -82,12 +85,24 @@ public class PageViewModelFactory : IPageViewModelFactory return ActivatorUtilities.CreateInstance(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } + private FileExplorerPageViewModel CreateFileExplorerPageViewModel(IScreen screen) + { + var logger = _serviceProvider.GetService>(); + return ActivatorUtilities.CreateInstance(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + private UdpClientPageViewModel CreateUdpClientPageViewModel(IScreen screen) { var logger = _serviceProvider.GetService>(); return ActivatorUtilities.CreateInstance(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } + private TcpClientPageViewModel CreateTcpClientPageViewModel(IScreen screen) + { + var logger = _serviceProvider.GetService>(); + return ActivatorUtilities.CreateInstance(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } + private UdpServerPageViewModel CreateUdpServerPageViewModel(IScreen screen) { var logger = _serviceProvider.GetService>(); diff --git a/AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs b/AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs new file mode 100644 index 0000000..63c4b83 --- /dev/null +++ b/AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs @@ -0,0 +1,151 @@ +using AuroraDesk.Presentation.ViewModels.Base; +using ReactiveUI; +using System.Collections.Generic; +using System.Linq; + +namespace AuroraDesk.Presentation.ViewModels.Pages; + +/// +/// 基于 Skottie 的呼吸动画页面 ViewModel +/// +public class BreathePageViewModel : RoutableViewModel +{ + private readonly IList _availableAnimations; + private int _repeatCount = Avalonia.Skia.Lottie.Lottie.Infinity; + private string _animationPath = "avares://AuroraDesk.Presentation/Resources/Animations/breathe.json"; + private string _pageTitle = "呼吸动画"; + private string _pageDescription = "演示如何在 Avalonia 中使用 Skottie 播放 Lottie 动画。"; + private AnimationOption? _selectedAnimation; + private bool _isUpdatingSelection; + + /// + /// 构造函数 + /// + /// 宿主 Screen + public BreathePageViewModel(IScreen hostScreen) + : base(hostScreen, "BreatheAnimation") + { + _availableAnimations = + [ + new AnimationOption("呼吸引导", "avares://AuroraDesk.Presentation/Resources/Animations/breathe.json"), + new AnimationOption("呼吸引导 V2", "avares://AuroraDesk.Presentation/Resources/Animations/breatheV2.json"), + new AnimationOption("科技律动 V3", "avares://AuroraDesk.Presentation/Resources/Animations/breathe_V3.json"), + ]; + + _selectedAnimation = _availableAnimations.FirstOrDefault(x => x.Path == _animationPath) + ?? _availableAnimations.FirstOrDefault(); + + if (_selectedAnimation is not null && _selectedAnimation.Path != _animationPath) + { + _animationPath = _selectedAnimation.Path; + } + } + + /// + /// 动画文件的资源路径(avares URI) + /// + public string AnimationPath + { + get => _animationPath; + set + { + if (value == _animationPath) + { + return; + } + + this.RaiseAndSetIfChanged(ref _animationPath, value); + + if (_isUpdatingSelection) + { + return; + } + + try + { + _isUpdatingSelection = true; + var match = _availableAnimations.FirstOrDefault(x => x.Path == value); + SelectedAnimation = match; + } + finally + { + _isUpdatingSelection = false; + } + } + } + + /// + /// 动画重复次数,默认无限循环 + /// + public int RepeatCount + { + get => _repeatCount; + set => this.RaiseAndSetIfChanged(ref _repeatCount, value); + } + + /// + /// 页面标题 + /// + public string PageTitle + { + get => _pageTitle; + set => this.RaiseAndSetIfChanged(ref _pageTitle, value); + } + + /// + /// 页面描述 + /// + public string PageDescription + { + get => _pageDescription; + set => this.RaiseAndSetIfChanged(ref _pageDescription, value); + } + + /// + /// 可供选择的动画资源列表 + /// + public IReadOnlyList AvailableAnimations => (IReadOnlyList)_availableAnimations; + + /// + /// 当前选中的动画资源 + /// + public AnimationOption? SelectedAnimation + { + get => _selectedAnimation; + set + { + if (ReferenceEquals(_selectedAnimation, value)) + { + return; + } + + this.RaiseAndSetIfChanged(ref _selectedAnimation, value); + + if (_isUpdatingSelection) + { + return; + } + + if (value is null) + { + return; + } + + try + { + _isUpdatingSelection = true; + AnimationPath = value.Path; + } + finally + { + _isUpdatingSelection = false; + } + } + } + + /// + /// 表示可选动画的显示名称与路径 + /// + public record AnimationOption(string DisplayName, string Path); +} + diff --git a/AuroraDesk.Presentation/ViewModels/Pages/FileExplorerPageViewModel.cs b/AuroraDesk.Presentation/ViewModels/Pages/FileExplorerPageViewModel.cs new file mode 100644 index 0000000..3346429 --- /dev/null +++ b/AuroraDesk.Presentation/ViewModels/Pages/FileExplorerPageViewModel.cs @@ -0,0 +1,560 @@ +using AuroraDesk.Presentation.ViewModels.Base; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using Avalonia.Threading; +using AvaloniaEdit.Document; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading.Tasks; + +namespace AuroraDesk.Presentation.ViewModels.Pages; + +/// +/// 目录文件浏览页面 ViewModel +/// +public class FileExplorerPageViewModel : RoutableViewModel +{ + private readonly ILogger? _logger; + private readonly ObservableCollection _treeItems = new(); + private readonly ObservableCollection _filteredTreeItems = new(); + private readonly ObservableCollection _availableHighlightLanguages = new(); + private readonly Dictionary _extensionLanguageMap; + + private string _selectedDirectory = string.Empty; + private FileTreeNode? _selectedNode; + private string _statusMessage = "请选择上方目录以加载文件树"; + private int _totalFileCount; + private bool _isLoading; + private readonly TextDocument _document; + private string _selectedHighlightLanguage = AutoDetectLanguageOption; + private string? _detectedHighlightLanguage; + private string _searchQuery = string.Empty; + + public FileExplorerPageViewModel( + IScreen hostScreen, + ILogger? logger = null) + : base(hostScreen, "FileExplorer") + { + _logger = logger; + _document = new TextDocument("// 请选择左侧树中的文件以预览内容"); + + _extensionLanguageMap = CreateExtensionLanguageMap(); + InitializeHighlightLanguages(); + + var canRefresh = this.WhenAnyValue( + x => x.SelectedDirectory, + dir => !string.IsNullOrWhiteSpace(dir) && Directory.Exists(dir)); + + BrowseDirectoryCommand = ReactiveCommand.CreateFromTask(BrowseDirectoryAsync); + RefreshDirectoryCommand = ReactiveCommand.CreateFromTask(RefreshCurrentDirectoryAsync, canRefresh); + + ApplyFilter(); + } + + public ObservableCollection TreeItems => _treeItems; + + public ObservableCollection FilteredTreeItems => _filteredTreeItems; + + public IReadOnlyList AvailableHighlightLanguages => _availableHighlightLanguages; + + public string SearchQuery + { + get => _searchQuery; + set + { + if (value == _searchQuery) + { + return; + } + + this.RaiseAndSetIfChanged(ref _searchQuery, value); + ApplyFilter(); + } + } + + public string SelectedHighlightLanguage + { + get => _selectedHighlightLanguage; + set + { + if (value == _selectedHighlightLanguage) + { + return; + } + + this.RaiseAndSetIfChanged(ref _selectedHighlightLanguage, value); + this.RaisePropertyChanged(nameof(ActiveHighlightLanguage)); + } + } + + public string ActiveHighlightLanguage => + SelectedHighlightLanguage == AutoDetectLanguageOption + ? _detectedHighlightLanguage ?? PlainTextLanguage + : SelectedHighlightLanguage; + + public FileTreeNode? SelectedNode + { + get => _selectedNode; + set + { + this.RaiseAndSetIfChanged(ref _selectedNode, value); + this.RaisePropertyChanged(nameof(SelectedFileName)); + this.RaisePropertyChanged(nameof(SelectedFileInfo)); + + if (value is null) + { + StatusMessage = "请选择左侧树中的文件"; + _ = UpdateDocumentAsync("// 尚未选择文件"); + } + else if (value.IsDirectory) + { + StatusMessage = $"已选中文件夹:{value.Name}"; + _ = UpdateDocumentAsync("// 当前为文件夹,请展开并点击文件查看内容"); + } + else + { + _ = LoadFileContentAsync(value); + } + } + } + + public string SelectedDirectory + { + get => _selectedDirectory; + set => this.RaiseAndSetIfChanged(ref _selectedDirectory, value); + } + + public string StatusMessage + { + get => _statusMessage; + set => this.RaiseAndSetIfChanged(ref _statusMessage, value); + } + + public int TotalFileCount + { + get => _totalFileCount; + set => this.RaiseAndSetIfChanged(ref _totalFileCount, value); + } + + public bool IsLoading + { + get => _isLoading; + set => this.RaiseAndSetIfChanged(ref _isLoading, value); + } + + public TextDocument Document => _document; + + public string SelectedFileName => SelectedNode switch + { + null => "尚未选择文件", + { IsDirectory: true } dir => $"{dir.Name}(文件夹)", + { } file => file.Name + }; + + public string SelectedFileInfo => SelectedNode switch + { + null => "在左侧文件树中点击文件以查看内容", + { IsDirectory: true } dir => $"{dir.Children.Count} 项 · 更新于 {dir.LastModified:yyyy-MM-dd HH:mm:ss}", + { } file => $"{FormatFileSize(file.Size)} · 更新于 {file.LastModified:yyyy-MM-dd HH:mm:ss}" + }; + + public ReactiveCommand BrowseDirectoryCommand { get; } + + public ReactiveCommand RefreshDirectoryCommand { get; } + + private async Task BrowseDirectoryAsync() + { + try + { + var topLevel = GetMainWindow(); + if (topLevel?.StorageProvider == null) + { + StatusMessage = "无法打开目录选择器"; + _logger?.LogWarning("StorageProvider 不可用"); + return; + } + + var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + AllowMultiple = false, + Title = "选择要浏览的文件夹" + }); + + if (folders.Count == 0) + { + return; + } + + if (folders[0].TryGetLocalPath() is { } path) + { + await LoadDirectoryAsync(path); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "选择目录失败"); + StatusMessage = $"选择目录失败:{ex.Message}"; + } + } + + private async Task RefreshCurrentDirectoryAsync() + { + if (string.IsNullOrWhiteSpace(SelectedDirectory)) + { + return; + } + + await LoadDirectoryAsync(SelectedDirectory); + } + + private async Task LoadDirectoryAsync(string directoryPath) + { + if (!Directory.Exists(directoryPath)) + { + StatusMessage = "目标目录不存在"; + return; + } + + try + { + IsLoading = true; + StatusMessage = "正在生成文件树..."; + SelectedDirectory = directoryPath; + SelectedNode = null; + await UpdateDocumentAsync("// 请选择左侧树中的文件以预览内容"); + + var result = await Task.Run(() => BuildDirectoryTree(directoryPath)); + + await Dispatcher.UIThread.InvokeAsync(() => + { + _treeItems.Clear(); + if (result.Root != null) + { + _treeItems.Add(result.Root); + } + + TotalFileCount = result.FileCount; + ApplyFilter(); + }); + + StatusMessage = result.FileCount == 0 + ? "目录中没有文件" + : $"共 {result.FileCount} 个文件,展开树后点击文件即可预览"; + } + catch (Exception ex) + { + _logger?.LogError(ex, "加载目录失败: {Directory}", directoryPath); + StatusMessage = $"加载目录失败:{ex.Message}"; + } + finally + { + IsLoading = false; + } + } + + private async Task LoadFileContentAsync(FileTreeNode node) + { + try + { + StatusMessage = $"正在读取 {node.Name}..."; + var content = await Task.Run(() => File.ReadAllText(node.FullPath)); + await Dispatcher.UIThread.InvokeAsync(() => + { + Document.Text = content; + }); + + UpdateDetectedLanguage(node.FullPath); + + StatusMessage = $"已加载 {node.Name}"; + } + catch (Exception ex) + { + _logger?.LogError(ex, "读取文件失败: {File}", node.FullPath); + StatusMessage = $"读取文件失败:{ex.Message}"; + await Dispatcher.UIThread.InvokeAsync(() => + { + Document.Text = $"// 无法读取文件:{ex.Message}"; + }); + } + } + + private async Task UpdateDocumentAsync(string placeholder) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + Document.Text = placeholder; + }); + } + + private DirectoryTreeResult BuildDirectoryTree(string directoryPath) + { + try + { + var rootInfo = new DirectoryInfo(directoryPath); + var (node, count) = CreateDirectoryNode(rootInfo); + return new DirectoryTreeResult(node, count); + } + catch (Exception ex) + { + _logger?.LogError(ex, "构建目录树失败: {Directory}", directoryPath); + return new DirectoryTreeResult(null, 0); + } + } + + private (FileTreeNode node, int fileCount) CreateDirectoryNode(DirectoryInfo directoryInfo) + { + var node = new FileTreeNode(directoryInfo.Name, directoryInfo.FullName, true, 0, directoryInfo.LastWriteTime); + var children = new List(); + var fileCount = 0; + + try + { + foreach (var subDir in directoryInfo.EnumerateDirectories()) + { + try + { + var (childNode, childFiles) = CreateDirectoryNode(subDir); + children.Add(childNode); + fileCount += childFiles; + } + catch (UnauthorizedAccessException) + { + _logger?.LogWarning("无权限访问目录: {Directory}", subDir.FullName); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "处理目录失败: {Directory}", subDir.FullName); + } + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "枚举目录失败: {Directory}", directoryInfo.FullName); + } + + try + { + foreach (var file in directoryInfo.EnumerateFiles()) + { + try + { + var fileNode = new FileTreeNode(file.Name, file.FullName, false, file.Length, file.LastWriteTime); + children.Add(fileNode); + fileCount++; + } + catch (UnauthorizedAccessException) + { + _logger?.LogWarning("无权限访问文件: {File}", file.FullName); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "读取文件信息失败: {File}", file.FullName); + } + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "枚举文件失败: {Directory}", directoryInfo.FullName); + } + + var orderedChildren = children + .OrderByDescending(c => c.IsDirectory) + .ThenBy(c => c.Name, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + + foreach (var child in orderedChildren) + { + node.Children.Add(child); + } + + return (node, fileCount); + } + + private void InitializeHighlightLanguages() + { + string[] defaults = + [ + AutoDetectLanguageOption, + PlainTextLanguage, + "C#", + "JavaScript", + "TypeScript", + "HTML", + "CSS", + "JSON", + "XML", + "YAML", + "Markdown", + "Python", + "Java", + "SQL" + ]; + + foreach (var lang in defaults) + { + _availableHighlightLanguages.Add(lang); + } + } + + private Dictionary CreateExtensionLanguageMap() => + new(StringComparer.OrdinalIgnoreCase) + { + [".cs"] = "C#", + [".js"] = "JavaScript", + [".ts"] = "TypeScript", + [".html"] = "HTML", + [".htm"] = "HTML", + [".css"] = "CSS", + [".json"] = "JSON", + [".xml"] = "XML", + [".yml"] = "YAML", + [".yaml"] = "YAML", + [".md"] = "Markdown", + [".py"] = "Python", + [".java"] = "Java", + [".sql"] = "SQL" + }; + + private void UpdateDetectedLanguage(string filePath) + { + var language = DetectLanguageFromExtension(Path.GetExtension(filePath)); + + if (language != _detectedHighlightLanguage) + { + _detectedHighlightLanguage = language; + this.RaisePropertyChanged(nameof(ActiveHighlightLanguage)); + } + } + + private string DetectLanguageFromExtension(string? extension) + { + if (extension != null && _extensionLanguageMap.TryGetValue(extension, out var lang)) + { + return lang; + } + + return PlainTextLanguage; + } + + private void ApplyFilter() + { + var keyword = SearchQuery?.Trim(); + var useFilter = !string.IsNullOrWhiteSpace(keyword); + + _filteredTreeItems.Clear(); + + if (!useFilter) + { + foreach (var item in _treeItems) + { + _filteredTreeItems.Add(item); + } + + return; + } + + foreach (var item in _treeItems) + { + var filteredNode = CloneNodeWithFilter(item, keyword!); + if (filteredNode != null) + { + _filteredTreeItems.Add(filteredNode); + } + } + + SelectedNode = null; + } + + private FileTreeNode? CloneNodeWithFilter(FileTreeNode source, string keyword) + { + var comparison = StringComparison.CurrentCultureIgnoreCase; + var selfMatch = source.Name.Contains(keyword, comparison); + var filteredChildren = new List(); + + foreach (var child in source.Children) + { + var filteredChild = CloneNodeWithFilter(child, keyword); + if (filteredChild != null) + { + filteredChildren.Add(filteredChild); + } + } + + if (!selfMatch && filteredChildren.Count == 0) + { + return null; + } + + var clone = new FileTreeNode(source.Name, source.FullPath, source.IsDirectory, source.Size, source.LastModified); + foreach (var child in filteredChildren) + { + clone.Children.Add(child); + } + + return clone; + } + + private static Window? GetMainWindow() + { + var app = Avalonia.Application.Current; + if (app?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + return desktop.MainWindow; + } + + return null; + } + + private static string FormatFileSize(long sizeInBytes) + { + if (sizeInBytes < 1024) + return $"{sizeInBytes} B"; + double size = sizeInBytes / 1024d; + if (size < 1024) + return $"{size:F1} KB"; + size /= 1024; + if (size < 1024) + return $"{size:F1} MB"; + size /= 1024; + return $"{size:F1} GB"; + } + + public sealed class FileTreeNode + { + public FileTreeNode(string name, string fullPath, bool isDirectory, long size, DateTime lastModified) + { + Name = name; + FullPath = fullPath; + IsDirectory = isDirectory; + Size = size; + LastModified = lastModified; + Children = new ObservableCollection(); + } + + public string Name { get; } + public string FullPath { get; } + public bool IsDirectory { get; } + public long Size { get; } + public DateTime LastModified { get; } + public ObservableCollection Children { get; } + + public string SizeDisplay => IsDirectory + ? $"{Children.Count} 项" + : FormatFileSize(Size); + } + + private sealed record DirectoryTreeResult(FileTreeNode? Root, int FileCount); + + private const string AutoDetectLanguageOption = "自动检测"; + private const string PlainTextLanguage = "Plain Text"; +} + diff --git a/AuroraDesk.Presentation/ViewModels/Pages/TcpClientPageViewModel.cs b/AuroraDesk.Presentation/ViewModels/Pages/TcpClientPageViewModel.cs new file mode 100644 index 0000000..38ecb09 --- /dev/null +++ b/AuroraDesk.Presentation/ViewModels/Pages/TcpClientPageViewModel.cs @@ -0,0 +1,203 @@ +using AuroraDesk.Presentation.ViewModels.Base; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using System; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Disposables; + +namespace AuroraDesk.Presentation.ViewModels.Pages; + +public class TcpClientPageViewModel : RoutableViewModel +{ + private readonly ILogger? _logger; + private readonly ObservableCollection _sessions = new(); + private readonly Dictionary _sessionSubscriptions = new(); + + private TcpClientSessionViewModel? _selectedSession; + private string _globalStatusMessage = "尚未创建会话"; + private int _sessionCounter = 1; + + public TcpClientPageViewModel( + IScreen hostScreen, + ILogger? logger = null) + : base(hostScreen, "TcpClient") + { + _logger = logger; + + Sessions = new ReadOnlyObservableCollection(_sessions); + + AddSessionCommand = ReactiveCommand.Create(AddSession); + RemoveSessionCommand = ReactiveCommand.Create(RemoveSession); + + _sessions.CollectionChanged += SessionsOnCollectionChanged; + UpdateAggregates(); + } + + public ReadOnlyObservableCollection Sessions { get; } + + public TcpClientSessionViewModel? SelectedSession + { + get => _selectedSession; + set => this.RaiseAndSetIfChanged(ref _selectedSession, value); + } + + public string GlobalStatusMessage + { + get => _globalStatusMessage; + private set => this.RaiseAndSetIfChanged(ref _globalStatusMessage, value); + } + + public int ActiveSessionCount => _sessions.Count(x => x.IsConnected); + + public int TotalSentMessages => _sessions.Sum(x => x.SentMessages.Count); + + public int TotalReceivedMessages => _sessions.Sum(x => x.ReceivedMessages.Count); + + public ReactiveCommand AddSessionCommand { get; } + + public ReactiveCommand RemoveSessionCommand { get; } + + private void AddSession() + { + var name = $"会话 {_sessionCounter++}"; + var session = new TcpClientSessionViewModel(name); + AttachSession(session); + _sessions.Add(session); + SelectedSession = session; + _logger?.LogInformation("已创建 TCP 会话 {Session}", name); + UpdateAggregates(); + } + + private void RemoveSession(TcpClientSessionViewModel? session) + { + if (session is null) + { + return; + } + + if (_sessions.Contains(session)) + { + DetachSession(session); + _sessions.Remove(session); + session.Dispose(); + _logger?.LogInformation("已移除 TCP 会话 {Session}", session.SessionName); + + if (ReferenceEquals(SelectedSession, session)) + { + SelectedSession = _sessions.LastOrDefault(); + } + + UpdateAggregates(); + } + } + + private void SessionsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems != null) + { + foreach (var item in e.NewItems.OfType()) + { + AttachSession(item); + } + } + + if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null) + { + foreach (var item in e.OldItems.OfType()) + { + DetachSession(item); + } + } + + if (e.Action == NotifyCollectionChangedAction.Reset) + { + foreach (var subscription in _sessionSubscriptions) + { + subscription.Value.Dispose(); + } + _sessionSubscriptions.Clear(); + } + + UpdateAggregates(); + } + + private void AttachSession(TcpClientSessionViewModel session) + { + if (_sessionSubscriptions.ContainsKey(session)) + { + return; + } + + void OnPropertyChanged(object? _, System.ComponentModel.PropertyChangedEventArgs args) + { + if (string.IsNullOrEmpty(args.PropertyName) || + args.PropertyName is nameof(TcpClientSessionViewModel.IsConnected) or + nameof(TcpClientSessionViewModel.SessionName) or + nameof(TcpClientSessionViewModel.StatusMessage)) + { + UpdateAggregates(); + } + } + + void OnCollectionChanged(object? _, NotifyCollectionChangedEventArgs __) => UpdateAggregates(); + void OnMetricsUpdated(object? _, EventArgs __) => UpdateAggregates(); + + session.PropertyChanged += OnPropertyChanged; + session.SentMessages.CollectionChanged += OnCollectionChanged; + session.ReceivedMessages.CollectionChanged += OnCollectionChanged; + session.MetricsUpdated += OnMetricsUpdated; + + var disposer = Disposable.Create(() => + { + session.PropertyChanged -= OnPropertyChanged; + session.SentMessages.CollectionChanged -= OnCollectionChanged; + session.ReceivedMessages.CollectionChanged -= OnCollectionChanged; + session.MetricsUpdated -= OnMetricsUpdated; + }); + + _sessionSubscriptions[session] = disposer; + } + + private void DetachSession(TcpClientSessionViewModel session) + { + if (_sessionSubscriptions.TryGetValue(session, out var disposer)) + { + disposer.Dispose(); + _sessionSubscriptions.Remove(session); + } + } + + private void UpdateAggregates() + { + this.RaisePropertyChanged(nameof(ActiveSessionCount)); + this.RaisePropertyChanged(nameof(TotalSentMessages)); + this.RaisePropertyChanged(nameof(TotalReceivedMessages)); + + GlobalStatusMessage = _sessions.Count switch + { + 0 => "尚未创建任何会话", + _ => $"共 {_sessions.Count} 个会话,活动 {_sessions.Count(x => x.IsConnected)} 个" + }; + } + + public void Dispose() + { + foreach (var session in _sessions.ToList()) + { + session.Dispose(); + } + + foreach (var subscription in _sessionSubscriptions) + { + subscription.Value.Dispose(); + } + + _sessionSubscriptions.Clear(); + _sessions.Clear(); + } +} + diff --git a/AuroraDesk.Presentation/ViewModels/Pages/TcpClientSessionViewModel.cs b/AuroraDesk.Presentation/ViewModels/Pages/TcpClientSessionViewModel.cs new file mode 100644 index 0000000..2778b8e --- /dev/null +++ b/AuroraDesk.Presentation/ViewModels/Pages/TcpClientSessionViewModel.cs @@ -0,0 +1,473 @@ +using Avalonia.Threading; +using Microsoft.Extensions.Logging; +using ReactiveUI; +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.ObjectModel; +using System.IO; +using System.IO.Hashing; +using System.Net; +using System.Net.Sockets; +using System.Reactive; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace AuroraDesk.Presentation.ViewModels.Pages; + +public sealed class TcpClientSessionViewModel : ReactiveObject, IDisposable +{ + private const int MaxFrameLength = 1024 * 1024; // 1 MB 安全上限 + + private readonly ILogger? _logger; + + private TcpClient? _tcpClient; + private NetworkStream? _networkStream; + private CancellationTokenSource? _receiveCancellationTokenSource; + private int _receivedMessageIndex; + + private string _sessionName; + private string _serverIp = "127.0.0.1"; + private int _serverPort = 5000; + private int _localPort; + private bool _enableCrc; + private int _frameType = 1; + private bool _isConnected; + private bool _isAutoScrollToBottom = true; + private string _statusMessage = "未连接"; + private string _message = string.Empty; + private bool _isBusy; + + public TcpClientSessionViewModel( + string sessionName, + ILogger? logger = null) + { + _sessionName = sessionName; + _logger = logger; + + SentMessages = new ObservableCollection(); + ReceivedMessages = new ObservableCollection(); + + ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync, + this.WhenAnyValue(x => x.IsConnected, x => x.IsBusy, + (connected, busy) => !connected && !busy)); + DisconnectCommand = ReactiveCommand.Create(Disconnect, + this.WhenAnyValue(x => x.IsConnected, x => x.IsBusy, + (connected, busy) => connected && !busy)); + SendMessageCommand = ReactiveCommand.CreateFromTask(SendMessageAsync, + this.WhenAnyValue(x => x.IsConnected, x => x.Message, x => x.IsBusy, + (connected, message, busy) => connected && !busy && !string.IsNullOrWhiteSpace(message))); + ReconnectCommand = ReactiveCommand.CreateFromTask(ReconnectAsync, + this.WhenAnyValue(x => x.IsBusy, busy => !busy)); + ClearMessagesCommand = ReactiveCommand.Create(ClearMessages); + } + + public ObservableCollection SentMessages { get; } + + public ObservableCollection ReceivedMessages { get; } + + public ReactiveCommand ConnectCommand { get; } + + public ReactiveCommand DisconnectCommand { get; } + + public ReactiveCommand SendMessageCommand { get; } + + public ReactiveCommand ReconnectCommand { get; } + + public ReactiveCommand ClearMessagesCommand { get; } + + public string SessionName + { + get => _sessionName; + set => this.RaiseAndSetIfChanged(ref _sessionName, value); + } + + public string ServerIp + { + get => _serverIp; + set => this.RaiseAndSetIfChanged(ref _serverIp, value); + } + + public int ServerPort + { + get => _serverPort; + set => this.RaiseAndSetIfChanged(ref _serverPort, value); + } + + public int LocalPort + { + get => _localPort; + private set => this.RaiseAndSetIfChanged(ref _localPort, value); + } + + public bool EnableCrc + { + get => _enableCrc; + set => this.RaiseAndSetIfChanged(ref _enableCrc, value); + } + + public int FrameType + { + get => _frameType; + set + { + var clamped = Math.Clamp(value, 0, 0x7FFF); + this.RaiseAndSetIfChanged(ref _frameType, clamped); + } + } + + public bool IsConnected + { + get => _isConnected; + private set => this.RaiseAndSetIfChanged(ref _isConnected, value); + } + + public bool IsAutoScrollToBottom + { + get => _isAutoScrollToBottom; + set => this.RaiseAndSetIfChanged(ref _isAutoScrollToBottom, value); + } + + public string StatusMessage + { + get => _statusMessage; + private set => this.RaiseAndSetIfChanged(ref _statusMessage, value); + } + + public string Message + { + get => _message; + set => this.RaiseAndSetIfChanged(ref _message, value); + } + + public bool IsBusy + { + get => _isBusy; + private set => this.RaiseAndSetIfChanged(ref _isBusy, value); + } + + public event EventHandler? MetricsUpdated; + + private async Task ConnectAsync() + { + if (IsConnected || IsBusy) + { + return; + } + + try + { + IsBusy = true; + + if (!IPAddress.TryParse(ServerIp, out _)) + { + StatusMessage = $"无效的服务器地址: {ServerIp}"; + return; + } + + if (ServerPort is < 1 or > 65535) + { + StatusMessage = $"无效的端口号: {ServerPort}"; + return; + } + + var client = new TcpClient(); + await client.ConnectAsync(ServerIp, ServerPort); + + _tcpClient = client; + _networkStream = client.GetStream(); + LocalPort = ((IPEndPoint)client.Client.LocalEndPoint!).Port; + + IsConnected = true; + StatusMessage = $"已连接至 {ServerIp}:{ServerPort} (本地端口 {LocalPort})"; + _logger?.LogInformation("TCP 会话 {Session} 已连接到 {Server}:{Port}", SessionName, ServerIp, ServerPort); + + _receiveCancellationTokenSource = new CancellationTokenSource(); + _ = Task.Run(() => ReceiveLoopAsync(_receiveCancellationTokenSource.Token)); + } + catch (Exception ex) + { + _logger?.LogError(ex, "TCP 会话 {Session} 连接失败", SessionName); + StatusMessage = $"连接失败: {ex.Message}"; + DisconnectInternal(); + } + finally + { + IsBusy = false; + } + } + + private void Disconnect() + { + if (!IsConnected) + { + return; + } + + DisconnectInternal(); + StatusMessage = "已断开连接"; + _logger?.LogInformation("TCP 会话 {Session} 已断开", SessionName); + } + + private async Task ReconnectAsync() + { + if (IsBusy) + { + return; + } + + DisconnectInternal(); + await ConnectAsync(); + } + + private async Task SendMessageAsync() + { + if (!IsConnected || _networkStream is null) + { + StatusMessage = "尚未连接,无法发送"; + return; + } + + try + { + IsBusy = true; + + var payloadBytes = Encoding.UTF8.GetBytes(Message); + var hasPayload = payloadBytes.Length > 0; + var hasCrc = EnableCrc; + var baseType = (ushort)FrameType; + var frameType = hasCrc ? (ushort)(baseType | 0x8000) : baseType; + + var frameLength = 2 + payloadBytes.Length + (hasCrc ? 4 : 0); + if (frameLength > MaxFrameLength) + { + StatusMessage = $"帧长度超出限制 ({frameLength} 字节)"; + return; + } + + var totalLength = 4 + frameLength; + var buffer = ArrayPool.Shared.Rent(totalLength); + + try + { + var span = buffer.AsSpan(0, totalLength); + BinaryPrimitives.WriteInt32BigEndian(span[..4], frameLength); + BinaryPrimitives.WriteUInt16BigEndian(span.Slice(4, 2), frameType); + + if (hasPayload) + { + payloadBytes.AsSpan().CopyTo(span.Slice(6, payloadBytes.Length)); + } + + if (hasCrc) + { + var crc = Crc32.HashToUInt32(payloadBytes); + BinaryPrimitives.WriteUInt32BigEndian(span.Slice(6 + payloadBytes.Length, 4), crc); + } + + await _networkStream.WriteAsync(buffer.AsMemory(0, totalLength)); + await _networkStream.FlushAsync(); + + var timestamp = DateTime.Now.ToString("HH:mm:ss"); + var payloadPreview = hasPayload ? Encoding.UTF8.GetString(payloadBytes) : string.Empty; + var hexPreview = payloadBytes.Length > 0 ? BitConverter.ToString(payloadBytes).Replace("-", " ") : "无"; + var displayMessage = $"[{timestamp}] 类型=0x{frameType:X4} CRC={(hasCrc ? "开启" : "关闭")} PayloadLen={payloadBytes.Length} 文本=\"{payloadPreview}\" HEX={hexPreview}"; + + await Dispatcher.UIThread.InvokeAsync(() => + { + SentMessages.Add(displayMessage); + Message = string.Empty; + MetricsUpdated?.Invoke(this, EventArgs.Empty); + }); + + StatusMessage = "发送成功"; + _logger?.LogInformation("TCP 会话 {Session} 已发送 {Length} 字节,类型 0x{Type:X4}", SessionName, payloadBytes.Length, frameType); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "TCP 会话 {Session} 发送失败", SessionName); + StatusMessage = $"发送失败: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private async Task ReceiveLoopAsync(CancellationToken cancellationToken) + { + if (_networkStream is null) + { + return; + } + + var lengthBuffer = new byte[4]; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + var lengthRead = await ReadExactAsync(_networkStream, lengthBuffer, cancellationToken); + if (!lengthRead) + { + break; + } + + var frameLength = BinaryPrimitives.ReadInt32BigEndian(lengthBuffer); + if (frameLength <= 2 || frameLength > MaxFrameLength) + { + await AppendSystemMessageAsync($"收到非法帧长度: {frameLength}"); + continue; + } + + var frameBuffer = ArrayPool.Shared.Rent(frameLength); + try + { + var success = await ReadExactAsync(_networkStream, frameBuffer.AsMemory(0, frameLength), cancellationToken); + if (!success) + { + break; + } + + var frameSpan = frameBuffer.AsSpan(0, frameLength); + var rawType = BinaryPrimitives.ReadUInt16BigEndian(frameSpan[..2]); + var hasCrc = (rawType & 0x8000) != 0; + var baseType = (ushort)(rawType & 0x7FFF); + var payloadLength = frameLength - 2 - (hasCrc ? 4 : 0); + if (payloadLength < 0) + { + await AppendSystemMessageAsync($"帧格式错误,payload 长度为负: {payloadLength}"); + continue; + } + + var payloadSpan = frameSpan.Slice(2, payloadLength); + var timestamp = DateTime.Now.ToString("HH:mm:ss"); + var payloadText = payloadSpan.Length > 0 ? Encoding.UTF8.GetString(payloadSpan) : string.Empty; + var payloadHex = payloadSpan.Length > 0 ? BitConverter.ToString(payloadSpan.ToArray()).Replace("-", " ") : "无"; + string crcInfo = "无"; + + if (hasCrc) + { + var crcSpan = frameSpan.Slice(2 + payloadLength, 4); + var receivedCrc = BinaryPrimitives.ReadUInt32BigEndian(crcSpan); + var calculatedCrc = Crc32.HashToUInt32(payloadSpan); + crcInfo = $"0x{receivedCrc:X8} (校验{(receivedCrc == calculatedCrc ? "通过" : "失败")})"; + } + + var displayText = $"[{timestamp}] 类型=0x{rawType:X4} 基础=0x{baseType:X4} CRC={crcInfo} PayloadLen={payloadSpan.Length} 文本=\"{payloadText}\" HEX={payloadHex}"; + var index = Interlocked.Increment(ref _receivedMessageIndex); + + await Dispatcher.UIThread.InvokeAsync(() => + { + ReceivedMessages.Add(new TcpReceivedMessageEntry(index, displayText)); + MetricsUpdated?.Invoke(this, EventArgs.Empty); + }); + } + finally + { + ArrayPool.Shared.Return(frameBuffer); + } + } + } + catch (OperationCanceledException) + { + // 忽略取消 + } + catch (IOException ex) + { + _logger?.LogWarning(ex, "TCP 会话 {Session} 读取时发生 IO 异常", SessionName); + await AppendSystemMessageAsync($"读取失败: {ex.Message}"); + } + catch (Exception ex) + { + _logger?.LogError(ex, "TCP 会话 {Session} 接收数据出错", SessionName); + await AppendSystemMessageAsync($"接收异常: {ex.Message}"); + } + finally + { + DisconnectInternal(); + await Dispatcher.UIThread.InvokeAsync(() => + { + StatusMessage = "连接已关闭"; + }); + } + } + + private async Task ReadExactAsync(NetworkStream stream, Memory buffer, CancellationToken token) + { + var totalRead = 0; + while (totalRead < buffer.Length) + { + var read = await stream.ReadAsync(buffer.Slice(totalRead), token); + if (read == 0) + { + return false; + } + totalRead += read; + } + return true; + } + + private async Task AppendSystemMessageAsync(string message) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + var index = Interlocked.Increment(ref _receivedMessageIndex); + ReceivedMessages.Add(new TcpReceivedMessageEntry(index, $"[系统] {message}")); + MetricsUpdated?.Invoke(this, EventArgs.Empty); + }); + } + + private void ClearMessages() + { + SentMessages.Clear(); + ReceivedMessages.Clear(); + Interlocked.Exchange(ref _receivedMessageIndex, 0); + MetricsUpdated?.Invoke(this, EventArgs.Empty); + _logger?.LogInformation("TCP 会话 {Session} 已清空消息", SessionName); + } + + private void DisconnectInternal() + { + try + { + IsBusy = true; + + _receiveCancellationTokenSource?.Cancel(); + _receiveCancellationTokenSource?.Dispose(); + _receiveCancellationTokenSource = null; + + _networkStream?.Close(); + _networkStream?.Dispose(); + _networkStream = null; + + _tcpClient?.Close(); + _tcpClient?.Dispose(); + _tcpClient = null; + + IsConnected = false; + } + catch (Exception ex) + { + _logger?.LogError(ex, "TCP 会话 {Session} 断开连接时出错", SessionName); + } + finally + { + IsBusy = false; + } + } + + public void Dispose() + { + DisconnectInternal(); + } +} + +public sealed record TcpReceivedMessageEntry(int Index, string DisplayText); + diff --git a/AuroraDesk.Presentation/Views/MainWindow.axaml b/AuroraDesk.Presentation/Views/MainWindow.axaml index 046277e..6bdcf40 100644 --- a/AuroraDesk.Presentation/Views/MainWindow.axaml +++ b/AuroraDesk.Presentation/Views/MainWindow.axaml @@ -15,7 +15,8 @@ MinWidth="1000" MinHeight="600" ExtendClientAreaToDecorationsHint="True" ExtendClientAreaChromeHints="NoChrome" - SystemDecorations="None"> + SystemDecorations="None" + WindowState="Maximized"> @@ -229,22 +230,6 @@ Foreground="{StaticResource SecondaryGrayStroke}"/> - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml.cs b/AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml.cs new file mode 100644 index 0000000..e6bcdc8 --- /dev/null +++ b/AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml.cs @@ -0,0 +1,140 @@ +using AuroraDesk.Presentation.ViewModels.Pages; +using System; +using System.Collections.Specialized; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Avalonia.VisualTree; +using ReactiveUI; +using ReactiveUI.Avalonia; + +namespace AuroraDesk.Presentation.Views.Pages; + +public partial class TcpClientPageView : ReactiveUserControl +{ + private ContentControl? _sessionDetailContent; + private ListBox? _receivedMessagesListBox; + private TcpClientSessionViewModel? _currentSession; + private IDisposable? _messagesSubscription; + private IDisposable? _autoScrollSubscription; + + public TcpClientPageView() + { + InitializeComponent(); + + this.WhenActivated(disposables => + { + _sessionDetailContent ??= this.FindControl("SessionDetailContent"); + + var sessionSubscription = this.WhenAnyValue(x => x.ViewModel) + .WhereNotNull() + .Select(vm => vm.WhenAnyValue(v => v.SelectedSession)) + .Switch() + .Subscribe(OnSessionChanged); + + disposables.Add(sessionSubscription); + disposables.Add(Disposable.Create(ClearSessionState)); + }); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void OnSessionChanged(TcpClientSessionViewModel? session) + { + if (ReferenceEquals(_currentSession, session)) + { + UpdateReceivedMessagesListBox(); + return; + } + + ClearSessionState(); + + _currentSession = session; + UpdateReceivedMessagesListBox(); + + if (session is null) + { + return; + } + + _messagesSubscription = Observable.FromEventPattern( + h => session.ReceivedMessages.CollectionChanged += h, + h => session.ReceivedMessages.CollectionChanged -= h) + .Subscribe(_ => CheckAutoScroll()); + + _autoScrollSubscription = session.WhenAnyValue(x => x.IsAutoScrollToBottom) + .Subscribe(_ => CheckAutoScroll()); + + CheckAutoScroll(); + } + + private void CheckAutoScroll() + { + if (_currentSession?.IsAutoScrollToBottom == true) + { + ScrollToBottom(); + } + } + + private void ClearSessionState() + { + _messagesSubscription?.Dispose(); + _messagesSubscription = null; + + _autoScrollSubscription?.Dispose(); + _autoScrollSubscription = null; + + _currentSession = null; + } + + private void UpdateReceivedMessagesListBox() + { + if (_sessionDetailContent is null) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + _receivedMessagesListBox = _sessionDetailContent + .GetVisualDescendants() + .OfType() + .FirstOrDefault(x => x.Name == "SessionReceivedMessagesListBox"); + + CheckAutoScroll(); + }, DispatcherPriority.Background); + } + + private void ScrollToBottom() + { + if (_receivedMessagesListBox is null || _currentSession is null) + { + return; + } + + if (_currentSession.ReceivedMessages.Count == 0) + { + return; + } + + var lastItem = _currentSession.ReceivedMessages[^1]; + + Dispatcher.UIThread.Post(() => + { + if (_receivedMessagesListBox is null) + { + return; + } + + _receivedMessagesListBox.ScrollIntoView(lastItem); + _receivedMessagesListBox.SelectedIndex = -1; + }, DispatcherPriority.Background); + } +} + diff --git a/AuroraDesk/modify.md b/AuroraDesk/modify.md index 83adfc8..f21cdbd 100644 --- a/AuroraDesk/modify.md +++ b/AuroraDesk/modify.md @@ -2,6 +2,22 @@ ## 2025年修改记录 +### 新增 Skottie breathe 动画演示页面 +- **日期**: 2025年11月14日 +- **修改内容**: 引入 `Avalonia.Skia.Lottie` 控件,在应用内新建“动画演示”页面并展示 `breathe.json` Lottie 动画,支持无限循环播放与主题化包装。 +- **涉及文件**: + - `AuroraDesk.Presentation/AuroraDesk.Presentation.csproj`(新增 `Avalonia.Skia.Lottie` 包引用) + - `AuroraDesk.Presentation/Resources/Animations/breathe.json`(整理动画资源路径) + - `AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs`(新增页面 ViewModel,暴露动画路径与描述) + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml`(新增 UI,使用 `lottie:Lottie` 控件渲染动画) + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml.cs`(初始化视图并预留生命周期钩子) + - `AuroraDesk.Presentation/Services/PageViewModelFactory.cs`(注册 `animation-breathe` 页面映射) + - `AuroraDesk.Infrastructure/Services/NavigationService.cs`(在导航中新增“动画演示”入口) +- **实现要点**: + - ✅ 引入 Skottie 控件并设置 `RepeatCount = Lottie.Infinity`,确保动画循环播放。 + - ✅ 使用 `avares://` 资源路径加载 `breathe.json`,并在视图中提供标题、说明及风格化容器。 + - ✅ 更新导航与 ViewLocator 体系,保证新页面可通过现有路由及 Tab 管理功能访问。 + ### ImageGalleryPageViewModel 优化大量图片时的恢复策略 - 避免7000+图片时的性能问题 - **日期**: 2025年1月 - **修改内容**: 优化视图重新激活时的缩略图恢复策略,避免在有大量图片(7000+)时触发大量异步操作 diff --git a/modify.md b/modify.md index b3fcabf..86a30af 100644 --- a/modify.md +++ b/modify.md @@ -2,6 +2,224 @@ ## 2025年修改记录 +### 新增文件浏览页面 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 新增 `FileExplorerPageViewModel` 与 `FileExplorerPageView`,实现“上方目录选择 + 下方左右布局”,左侧统计文件数量并展示可展开的文件树,右侧通过 AvaloniaEdit 预览内容(`xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"`)。 + 2. 将页面注册到 DI 与导航:`ServiceCollectionExtensions`、`PageViewModelFactory`、`NavigationService` 新增 `file-explorer` 项,侧边菜单可直接进入“文件浏览”工具。 + 3. 为顶部容器设置三列 `Grid.Column`,分别承载说明文本、目录路径文本框与操作按钮,避免控件堆叠;文件树标题/描述分别占 `Grid.Row="0/1"`,并改用 `TreeView` 展示目录层级,点击节点即可预览文件。 + 4. 右侧预览区加入“语法高亮”下拉框,绑定新属性 `SelectedHighlightLanguage`;支持自动按扩展名检测(C#/JSON/YAML/Markdown 等),并通过 `TextMateHelper` 在 `TextEditor` 中切换语言。 +- **涉及文件**: + - `AuroraDesk.Presentation/ViewModels/Pages/FileExplorerPageViewModel.cs` + - `AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml` + - `AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml.cs` + - `AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs` + - `AuroraDesk.Presentation/Services/PageViewModelFactory.cs` + - `AuroraDesk.Infrastructure/Services/NavigationService.cs` +- **效果**: + - ✅ 目录选择 + 列表 + AvaloniaEdit 预览一屏完成,方便快速阅读文件。 + - ✅ 顶部与列表区域不再发生文字重合,控件分区清晰。 + - ✅ 导航菜单出现“文件浏览”入口,可随时切换。 + +### 文件浏览页面新增搜索框 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 为 `FileExplorerPageViewModel` 新增 `SearchQuery`、`FilteredTreeItems` 以及节点克隆过滤逻辑,根据关键字实时筛选文件树。 + 2. 在 `LoadDirectoryAsync` 结束后自动同步筛选结果,保持计数信息与过滤列表一致。 + 3. 在 `FileExplorerPageView` 左侧文件树上方加入搜索框,输入内容时立即按名称匹配文件或文件夹。 +- **涉及文件**: + - `AuroraDesk.Presentation/ViewModels/Pages/FileExplorerPageViewModel.cs` + - `AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml` +- **效果**: + - ✅ 文件树可按关键字快速定位目标,提升大型目录下的浏览效率。 + - ✅ 过滤结果保持原有层级结构,仅展示匹配节点及其父级上下文。 + +### 调整 breathe 系列动画画布宽度 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `breathe.json` 与 `breatheV2.json` 的合成宽度从 680 提升至 960,并把两层文字图层的 `p.x` 调整为 480(新宽度的一半),留足左右余量。 + 2. 保持高度和缩放不变,仅扩充画布以容纳霓虹外发光区域。 +- **涉及文件**: + - `AuroraDesk.Presentation/Resources/Animations/breathe.json` + - `AuroraDesk.Presentation/Resources/Animations/breatheV2.json` +- **效果**: + - ✅ “AVALONIA” 与 “WELCOME BACK” 再无左右被裁剪的问题,光晕完整可见。 + +### 修复 breathe 动画字体缺失导致崩溃 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `breathe.json` 与 `breatheV2.json` 的字体信息由 `STHupo` 改为应用内置的 `Inter`,避免依赖系统是否安装华文琥珀字体。 + 2. 同步更新两段文本样式的 `f` 字段,并把填充色改为高饱和蓝色,保证浅色背景下也能明显看到文字。 + 3. 修复 `breathe.json` 中 Drop Shadow 参数名出现乱码导致 JSON 无效的问题,改用英文字符串(Opacity/Softness/Shadow Only)。 +- **涉及文件**: + - `AuroraDesk.Presentation/Resources/Animations/breathe.json` + - `AuroraDesk.Presentation/Resources/Animations/breatheV2.json` +- **效果**: + - ✅ `breathe.json`/`breatheV2.json` 在任意设备上都能立即显示文本内容,不再出现空白。 + - ✅ Tab 之间切换不再触发 `System.ExecutionEngineException`,Lottie 控件释放/重建稳定。 + +### 重构呼吸页面并解除 Lottie 裁剪 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 重新组织 `BreathePageView` 布局:顶部保留标题区,下方改为“左侧控制面板 + 右侧预览卡片”的两列结构,加入动画资源信息卡、播放设置卡与更醒目的 V3 提示。 + 2. 右侧预览区采用深色画布 + `Viewbox` 自适应缩放,整体卡片使用大圆角与更充足的留白,视觉风格与其他页面保持一致。 + 3. 为动画容器和 `lottie:Lottie` 控件设置 `ClipToBounds="False"`,并嵌入 `Viewbox`,确保 `breatheV2.json` 中带有负偏移的霓虹光晕不会被边框裁剪。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 呼吸页面的基础信息、动画选择与播放状态分区明确,界面更现代。 + - ✅ Lottie 动画可完整展示 “WELCOME BACK” 文本与外扩光效,彻底消除左侧被截断的问题。 + +### 优化呼吸动画实时预览卡片 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `BreathePageView` 右侧预览区域由深色背景改为浅色渐变卡片,采用双层圆角 + 柔和描边,与整页风格保持一致。 + 2. 保留 `Viewbox` 自适应缩放并添加提示语与所选动画名称徽章,确保 `breathe.json` / `breatheV2.json` 等不同画布能在统一边界内完整显示。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 预览卡片视觉更轻盈,与左侧控制面板色调统一。 + - ✅ 动画得到充分留白,宽幅文本不再与黑背景冲突。 + +### 调整 TCP 客户端消息输入位置 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `TcpClientPageView` 中的消息输入框和发送按钮移至会话详情底部的独立卡片,避免与“已发送消息”列表混排。 + 2. 保留原有列表虚拟化结构,接收消息区域布局不受影响。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml` +- **效果**: + - ✅ 发送输入区位置更符合用户预期,界面层级更清晰。 + - ✅ 接收消息容器与统计展示保持原样,交互逻辑未受影响。 + +### 调整呼吸动画展示区域铺满 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `BreathePageView` 动画容器的对齐方式改为水平/垂直拉伸,允许边框随父容器铺满。 + 2. 移除 Lottie 控件的固定宽高,改用拉伸对齐以按可用空间呈现动画。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 呼吸动画在内容区域内自动拉伸显示,视觉更协调。 + - ✅ 保持圆角与边框效果,未引入布局断裂。 + +### 修复呼吸动画首字母被裁剪 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将预览区域 `Viewbox` 的 `MaxWidth` 提升至 1200,与最新 `breathe.json` 画布一致。 + 2. 移除 `Viewbox` 四周 36px 边距,确保动画可获得完整宽度。 + 3. 同步扩充 `breathe.json` 合成宽度并将文字位置改为 `x=600`,彻底消除首字母被裁剪的问题。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` + - `AuroraDesk.Presentation/Resources/Animations/breathe.json` +- **效果**: + - ✅ “AVALONIA” 文本首字母不再被裁剪,光晕完整显示。 + - ✅ 其它呼吸动画同样可利用更宽画布,避免边缘截断。 + +### 呼吸动画资源信息支持切换 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将动画资源说明文本改为绑定 `AnimationPath`,通过 `StringFormat` 展示当前路径。 + 2. 当页面切换不同动画资源时,文本会实时更新,无需修改 XAML。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 动画资源路径展示可随绑定变化自动刷新。 + - ✅ 便于后续通过 ViewModel 切换不同动画文件。 + +### 呼吸动画页面支持多个资源切换 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 为 `BreathePageViewModel` 新增动画资源列表与所选项绑定,预置 `breathe.json`、`capability.json`。 + 2. 在 `BreathePageView` 中加入下拉选择控件,绑定至 ViewModel,选项变更时自动更新动画路径。 +- **涉及文件**: + - `AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs` + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 页面可快速切换不同 Lottie 资源,无需修改代码。 + - ✅ 资源说明文本与动画展示同步更新,体验一致。 + +### 呼吸动画替换 capability 资源 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `breatheV2.json` 拷贝至 `Resources/Animations`,并在 `BreathePageViewModel` 中以“呼吸引导 V2”形式加入动画列表。 + 2. 移除无效的 `capability.json` 选项及对应文件,确保用户只看到可播放的 Lottie 资源。 +- **涉及文件**: + - `AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs` + - `AuroraDesk.Presentation/Resources/Animations/breatheV2.json` + - `AuroraDesk.Presentation/Resources/Animations/capability.json`(已删除) + - `capability.json`(已删除) +- **效果**: + - ✅ “能力演示”选项改为可用的 V2 动画,切换后立即生效。 + - ✅ 避免加载配置文件导致 Lottie 控件空白的问题。 + +### 优化 breatheV2 “WELCOME BACK” 动画 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `breatheV2.json` 的文本更新为 “WELCOME BACK” 单行霓虹效果,并应用蓝青色描边与荧光色填充,突出“欢迎回来”主题。 + 2. 调整双层投影的颜色、距离与柔和度,呈现更科幻的霓虹发光效果。 + 3. 多次迭代后采用左移 30px + 深度压缩布局(字号 95、缩放 60%、行距 120、轻微负字距),确保 680×280 画布内完整呈现、左右留白均匀。 +- **涉及文件**: + - `AuroraDesk.Presentation/Resources/Animations/breatheV2.json` +- **效果**: + - ✅ 新动画展现出更具科技感且完整的欢迎视觉,左右留白均衡。 + - ✅ Lottie 结构保持不变,仍可在 `BreathePageView` 中稳定循环播放。 + +### 缩放 breathe.json 避免截断 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将 `breathe.json` 两层文字的缩放从 100% 降至 85%,字号从 192 调整为 150,并把行距降至 180。 + 2. 文本对齐方式改为居中(`j=2`),确保 “AVALONIA” 在 680×280 画布内水平居中展示,不再出现左右裁切。 +- **涉及文件**: + - `AuroraDesk.Presentation/Resources/Animations/breathe.json` +- **效果**: + - ✅ 默认动画与 V2/V3 一样能完整显示,且视觉集中在容器正中。 + - ✅ Lottie 文件结构保持兼容,仍可在 Breathe 页面中循环播放。 + +### 新增 breathe_V3 科技律动动画 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 解除 `breathe_V3.json` 只读属性并纳入 `Resources/Animations`,供页面加载。 + 2. 在 `BreathePageViewModel` 的动画列表中新增“科技律动 V3”选项,路径指向 `breathe_V3.json`。 + 3. 在 `BreathePageView.axaml` 中补充提示卡片,引导用户选择最新动画资源。 +- **涉及文件**: + - `AuroraDesk.Presentation/Resources/Animations/breathe_V3.json` + - `AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs` + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 主页面可直接切换到 V3 动画,提示区域也醒目展示新资源信息。 + - ✅ 所有动画选项均为有效 Lottie 文件,避免空白加载。 + +### 调整动画显示尺寸避免内容被裁剪 +- **日期**: 2025年11月14日 +- **调整内容**: + 1. 将呼吸动画控件对齐方式改为居中,不再随容器拉伸。 + 2. 为 Lottie 控件增加最大宽高(680×280),保证文本在放大时不被裁剪。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml` +- **效果**: + - ✅ 动画文字保持完整可见,避免铺满后出现截断。 + - ✅ 仍然维持适度缩放,居中展示更清晰。 + +### 新增 TCP 客户端多会话视图 +- **日期**: 2025年11月13日 +- **调整内容**: + 1. 新建 `TcpClientPageView` XAML,延续 UDP 页面卡片化风格,新增顶部全局统计、左侧会话列表和右侧详情区,支持多会话并列管理。 + 2. 详情区围绕选中会话提供连接配置、帧类型/CRC 开关、发送与接收双栏布局,并引入自动置底、重连入口等交互元素。 + 3. 实现 `TcpClientPageViewModel` 与 `TcpClientSessionViewModel`,遵循「长度 + 类型 + payload (+ CRC32)」协议构帧,支持多会话异步连接、发送、接收与 CRC 校验。 + 4. 重写页面代码后台为强类型订阅,按选中会话自动定位到最新消息,同时更新导航、依赖注入以开放 `TCP 客户端` 菜单。 +- **涉及文件**: + - `AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml` + - `AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml.cs` + - `AuroraDesk.Presentation/ViewModels/Pages/TcpClientPageViewModel.cs` + - `AuroraDesk.Presentation/ViewModels/Pages/TcpClientSessionViewModel.cs` + - `AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs` + - `AuroraDesk.Presentation/Services/PageViewModelFactory.cs` + - `AuroraDesk.Infrastructure/Services/NavigationService.cs` +- **效果**: + - ✅ 一次性落地 TCP 多会话能力,自动完成协议封包、CRC 追加/校验与回显。 + - ✅ UI 与逻辑均已就绪,可直接在导航中创建/切换会话并查看实时收发记录。 + ### 为 UDP 客户端接收消息添加自动滚动控制 - **日期**: 2025年11月10日 - **调整内容**: