Browse Source

加一个特性 跟 文件

refactor/namespace-and-layering
root 1 month ago
parent
commit
a698929eb6
  1. 30
      AuroraDesk.Infrastructure/Services/NavigationService.cs
  2. 2
      AuroraDesk.Presentation/AuroraDesk.Presentation.csproj
  3. 2
      AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs
  4. 359
      AuroraDesk.Presentation/Resources/Animations/breathe.json
  5. 366
      AuroraDesk.Presentation/Resources/Animations/breatheV2.json
  6. 84
      AuroraDesk.Presentation/Resources/Animations/breathe_V3.json
  7. 15
      AuroraDesk.Presentation/Services/PageViewModelFactory.cs
  8. 151
      AuroraDesk.Presentation/ViewModels/Pages/BreathePageViewModel.cs
  9. 560
      AuroraDesk.Presentation/ViewModels/Pages/FileExplorerPageViewModel.cs
  10. 203
      AuroraDesk.Presentation/ViewModels/Pages/TcpClientPageViewModel.cs
  11. 473
      AuroraDesk.Presentation/ViewModels/Pages/TcpClientSessionViewModel.cs
  12. 19
      AuroraDesk.Presentation/Views/MainWindow.axaml
  13. 2
      AuroraDesk.Presentation/Views/MainWindow.axaml.cs
  14. 189
      AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml
  15. 23
      AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml.cs
  16. 182
      AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml
  17. 22
      AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml.cs
  18. 1038
      AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml
  19. 140
      AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml.cs
  20. 16
      AuroraDesk/modify.md
  21. 218
      modify.md

30
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<NavigationItem>
{
new NavigationItem
{
Id = "tcp-client",
Title = "TCP 客户端",
IconType = IconType.Link,
ViewModel = _pageViewModelFactory.CreatePageViewModel("tcp-client", screen)
}
}
},
new NavigationItem
{
Id = "ssh-tools",
Title = "SSH 工具",

2
AuroraDesk.Presentation/AuroraDesk.Presentation.csproj

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.8" />
<PackageReference Include="Avalonia.Skia.Lottie" Version="11.0.0" />
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
<PackageReference Include="DialogHost.Avalonia" Version="0.9.3" />
@ -20,6 +21,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="System.IO.Hashing" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

2
AuroraDesk.Presentation/Extensions/ServiceCollectionExtensions.cs

@ -45,8 +45,10 @@ public static class ServiceCollectionExtensions
services.AddTransient<DialogHostPageViewModel>();
services.AddTransient<IconsPageViewModel>();
services.AddTransient<EditorPageViewModel>();
services.AddTransient<FileExplorerPageViewModel>();
services.AddTransient<UdpClientPageViewModel>();
services.AddTransient<UdpServerPageViewModel>();
services.AddTransient<TcpClientPageViewModel>();
services.AddTransient<NodeCanvasPageViewModel>();
services.AddTransient<PlinkPageViewModel>();
services.AddTransient<SshPageViewModel>();

359
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": {}
}

366
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": {}
}

84
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": []
}

15
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<BreathePageViewModel>(screen),
_ => throw new ArgumentException($"Unknown page: {pageId}", nameof(pageId))
};
}
@ -82,12 +85,24 @@ public class PageViewModelFactory : IPageViewModelFactory
return ActivatorUtilities.CreateInstance<ImageGalleryPageViewModel>(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ImageGalleryPageViewModel>.Instance);
}
private FileExplorerPageViewModel CreateFileExplorerPageViewModel(IScreen screen)
{
var logger = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILogger<FileExplorerPageViewModel>>();
return ActivatorUtilities.CreateInstance<FileExplorerPageViewModel>(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<FileExplorerPageViewModel>.Instance);
}
private UdpClientPageViewModel CreateUdpClientPageViewModel(IScreen screen)
{
var logger = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILogger<UdpClientPageViewModel>>();
return ActivatorUtilities.CreateInstance<UdpClientPageViewModel>(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<UdpClientPageViewModel>.Instance);
}
private TcpClientPageViewModel CreateTcpClientPageViewModel(IScreen screen)
{
var logger = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILogger<TcpClientPageViewModel>>();
return ActivatorUtilities.CreateInstance<TcpClientPageViewModel>(_serviceProvider, screen, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<TcpClientPageViewModel>.Instance);
}
private UdpServerPageViewModel CreateUdpServerPageViewModel(IScreen screen)
{
var logger = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILogger<UdpServerPageViewModel>>();

151
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;
/// <summary>
/// 基于 Skottie 的呼吸动画页面 ViewModel
/// </summary>
public class BreathePageViewModel : RoutableViewModel
{
private readonly IList<AnimationOption> _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;
/// <summary>
/// 构造函数
/// </summary>
/// <param name="hostScreen">宿主 Screen</param>
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;
}
}
/// <summary>
/// 动画文件的资源路径(avares URI)
/// </summary>
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;
}
}
}
/// <summary>
/// 动画重复次数,默认无限循环
/// </summary>
public int RepeatCount
{
get => _repeatCount;
set => this.RaiseAndSetIfChanged(ref _repeatCount, value);
}
/// <summary>
/// 页面标题
/// </summary>
public string PageTitle
{
get => _pageTitle;
set => this.RaiseAndSetIfChanged(ref _pageTitle, value);
}
/// <summary>
/// 页面描述
/// </summary>
public string PageDescription
{
get => _pageDescription;
set => this.RaiseAndSetIfChanged(ref _pageDescription, value);
}
/// <summary>
/// 可供选择的动画资源列表
/// </summary>
public IReadOnlyList<AnimationOption> AvailableAnimations => (IReadOnlyList<AnimationOption>)_availableAnimations;
/// <summary>
/// 当前选中的动画资源
/// </summary>
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;
}
}
}
/// <summary>
/// 表示可选动画的显示名称与路径
/// </summary>
public record AnimationOption(string DisplayName, string Path);
}

560
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;
/// <summary>
/// 目录文件浏览页面 ViewModel
/// </summary>
public class FileExplorerPageViewModel : RoutableViewModel
{
private readonly ILogger<FileExplorerPageViewModel>? _logger;
private readonly ObservableCollection<FileTreeNode> _treeItems = new();
private readonly ObservableCollection<FileTreeNode> _filteredTreeItems = new();
private readonly ObservableCollection<string> _availableHighlightLanguages = new();
private readonly Dictionary<string, string> _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<FileExplorerPageViewModel>? 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<FileTreeNode> TreeItems => _treeItems;
public ObservableCollection<FileTreeNode> FilteredTreeItems => _filteredTreeItems;
public IReadOnlyList<string> 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<Unit, Unit> BrowseDirectoryCommand { get; }
public ReactiveCommand<Unit, Unit> 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<FileTreeNode>();
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<string, string> 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<FileTreeNode>();
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<FileTreeNode>();
}
public string Name { get; }
public string FullPath { get; }
public bool IsDirectory { get; }
public long Size { get; }
public DateTime LastModified { get; }
public ObservableCollection<FileTreeNode> 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";
}

203
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<TcpClientPageViewModel>? _logger;
private readonly ObservableCollection<TcpClientSessionViewModel> _sessions = new();
private readonly Dictionary<TcpClientSessionViewModel, IDisposable> _sessionSubscriptions = new();
private TcpClientSessionViewModel? _selectedSession;
private string _globalStatusMessage = "尚未创建会话";
private int _sessionCounter = 1;
public TcpClientPageViewModel(
IScreen hostScreen,
ILogger<TcpClientPageViewModel>? logger = null)
: base(hostScreen, "TcpClient")
{
_logger = logger;
Sessions = new ReadOnlyObservableCollection<TcpClientSessionViewModel>(_sessions);
AddSessionCommand = ReactiveCommand.Create(AddSession);
RemoveSessionCommand = ReactiveCommand.Create<TcpClientSessionViewModel?>(RemoveSession);
_sessions.CollectionChanged += SessionsOnCollectionChanged;
UpdateAggregates();
}
public ReadOnlyObservableCollection<TcpClientSessionViewModel> 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<Unit, Unit> AddSessionCommand { get; }
public ReactiveCommand<TcpClientSessionViewModel?, Unit> 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<TcpClientSessionViewModel>())
{
AttachSession(item);
}
}
if (e.Action == NotifyCollectionChangedAction.Remove && e.OldItems != null)
{
foreach (var item in e.OldItems.OfType<TcpClientSessionViewModel>())
{
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();
}
}

473
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<TcpClientSessionViewModel>? _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<TcpClientSessionViewModel>? logger = null)
{
_sessionName = sessionName;
_logger = logger;
SentMessages = new ObservableCollection<string>();
ReceivedMessages = new ObservableCollection<TcpReceivedMessageEntry>();
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<string> SentMessages { get; }
public ObservableCollection<TcpReceivedMessageEntry> ReceivedMessages { get; }
public ReactiveCommand<Unit, Unit> ConnectCommand { get; }
public ReactiveCommand<Unit, Unit> DisconnectCommand { get; }
public ReactiveCommand<Unit, Unit> SendMessageCommand { get; }
public ReactiveCommand<Unit, Unit> ReconnectCommand { get; }
public ReactiveCommand<Unit, Unit> 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<byte>.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<byte>.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<byte>.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<byte>.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<bool> ReadExactAsync(NetworkStream stream, Memory<byte> 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);

19
AuroraDesk.Presentation/Views/MainWindow.axaml

@ -15,7 +15,8 @@
MinWidth="1000" MinHeight="600"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaChromeHints="NoChrome"
SystemDecorations="None">
SystemDecorations="None"
WindowState="Maximized">
<Design.DataContext>
<vm:MainWindowViewModel />
@ -229,22 +230,6 @@
Foreground="{StaticResource SecondaryGrayStroke}"/>
</Button>
<!-- 最大化/还原按钮 -->
<Button Name="MaximizeButton"
Width="46" Height="30"
Background="Transparent"
BorderThickness="0"
tooltip:ToolTip.Tip="{StaticResource BtnMaximize}">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="Background" Value="{StaticResource BackgroundLight}"/>
</Style>
</Button.Styles>
<heroicons:HeroIcon Type="ArrowsPointingOut"
Width="16" Height="16"
Foreground="{StaticResource SecondaryGrayStroke}"/>
</Button>
<!-- 关闭按钮 -->
<Button Name="CloseButton"
Width="46" Height="30"

2
AuroraDesk.Presentation/Views/MainWindow.axaml.cs

@ -32,6 +32,7 @@ public partial class MainWindow : ReactiveWindow<MainWindowViewModel>, IActivata
public MainWindow()
{
InitializeComponent();
WindowState = WindowState.Maximized;
SetupWindowControls();
}
@ -47,6 +48,7 @@ public partial class MainWindow : ReactiveWindow<MainWindowViewModel>, IActivata
_logger = logger;
InitializeComponent();
ViewModel = viewModel;
WindowState = WindowState.Maximized;
SetupWindowControls();
_logger?.LogInformation("MainWindow 已创建,ViewModel 已设置");

189
AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml

@ -0,0 +1,189 @@
<reactive:ReactiveUserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:AuroraDesk.Presentation.ViewModels.Pages"
xmlns:reactive="using:ReactiveUI.Avalonia"
xmlns:lottie="clr-namespace:Avalonia.Skia.Lottie;assembly=Avalonia.Skia.Lottie"
mc:Ignorable="d"
d:DesignWidth="1100"
d:DesignHeight="700"
x:Class="AuroraDesk.Presentation.Views.Pages.BreathePageView"
x:DataType="vm:BreathePageViewModel">
<Design.DataContext>
<vm:BreathePageViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*"
Margin="0,0,0,24">
<!-- 顶部标题 -->
<StackPanel Grid.Row="0"
Margin="0,0,0,24"
Spacing="6">
<TextBlock Text="{Binding PageTitle}"
FontSize="30"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" />
<TextBlock Text="{Binding PageDescription}"
FontSize="15"
Foreground="{StaticResource SecondaryGrayDark}" />
</StackPanel>
<Grid Grid.Row="1"
ColumnDefinitions="360,*"
ColumnSpacing="24">
<!-- 左侧控制面板 -->
<StackPanel Grid.Column="0"
Spacing="16">
<Border Background="{StaticResource BackgroundWhite}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="14"
Padding="20">
<StackPanel Spacing="18">
<StackPanel Spacing="4">
<TextBlock Text="动画选择"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" />
<TextBlock Text="挑选不同风格的呼吸引导动画"
FontSize="13"
Foreground="{StaticResource SecondaryGrayDark}" />
</StackPanel>
<ComboBox ItemsSource="{Binding AvailableAnimations}"
SelectedItem="{Binding SelectedAnimation}"
DisplayMemberBinding="{Binding DisplayName}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
MaxDropDownHeight="220" />
<Border Background="{StaticResource BackgroundLight}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="10"
Padding="12">
<StackPanel Spacing="4">
<TextBlock Text="当前动画资源"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" />
<TextBlock Text="{Binding AnimationPath}"
TextWrapping="Wrap"
FontSize="12"
Foreground="{StaticResource SecondaryGrayDark}" />
</StackPanel>
</Border>
<Border Background="#E6F1FF"
BorderBrush="#B8D9FF"
BorderThickness="1"
CornerRadius="10"
Padding="12">
<StackPanel Spacing="4">
<TextBlock Text="新增动画:科技律动 V3"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource AccentBlueDark}" />
<TextBlock Text="更明亮的霓虹效果,适合大屏展示。"
FontSize="12"
Foreground="{StaticResource AccentBlueDark}" />
</StackPanel>
</Border>
</StackPanel>
</Border>
<Border Background="{StaticResource BackgroundWhite}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="14"
Padding="18">
<StackPanel Spacing="12">
<TextBlock Text="播放设置"
FontSize="15"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" />
<StackPanel Orientation="Horizontal"
Spacing="12"
VerticalAlignment="Center">
<TextBlock Text="循环模式"
FontSize="13"
Foreground="{StaticResource SecondaryGrayDark}" />
<TextBlock Text="无限循环(Lottie.Infinity)"
FontSize="13"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
<!-- 右侧预览面板 -->
<Border Grid.Column="1"
Background="{StaticResource BackgroundWhite}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="20"
Padding="28">
<Grid RowDefinitions="Auto,*">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="实时预览"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}" />
<TextBlock Text="(自动适配动画尺寸)"
FontSize="12"
Foreground="{StaticResource SecondaryGrayDark}" />
</StackPanel>
<Border Grid.Column="1"
Background="{StaticResource BackgroundLight}"
CornerRadius="12"
Padding="6,2">
<TextBlock Text="{Binding SelectedAnimation.DisplayName}"
FontSize="12"
Foreground="{StaticResource SecondaryGrayDark}" />
</Border>
</Grid>
<Grid Grid.Row="1"
Margin="0,18,0,0"
RowDefinitions="*,Auto">
<Border Background="#F7FAFF"
BorderBrush="#DEE7FF"
BorderThickness="1"
CornerRadius="18"
Padding="0"
ClipToBounds="False">
<Viewbox Stretch="Uniform"
StretchDirection="Both"
HorizontalAlignment="Center"
VerticalAlignment="Center"
MaxWidth="1200"
MaxHeight="420"
Margin="0">
<Border Background="Transparent"
ClipToBounds="False">
<lottie:Lottie Path="{Binding AnimationPath}"
RepeatCount="{Binding RepeatCount}"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center"
ClipToBounds="False" />
</Border>
</Viewbox>
</Border>
<TextBlock Grid.Row="1"
Margin="0,16,0,0"
Text="提示:若动画仍存在裁剪,可在资源文件中调整画布尺寸或缩放参数。"
FontSize="12"
Foreground="{StaticResource SecondaryGrayDark}" />
</Grid>
</Grid>
</Border>
</Grid>
</Grid>
</reactive:ReactiveUserControl>

23
AuroraDesk.Presentation/Views/Pages/BreathePageView.axaml.cs

@ -0,0 +1,23 @@
using ReactiveUI.Avalonia;
using Avalonia.Markup.Xaml;
using AuroraDesk.Presentation.ViewModels.Pages;
using ReactiveUI;
namespace AuroraDesk.Presentation.Views.Pages;
public partial class BreathePageView : ReactiveUserControl<BreathePageViewModel>
{
public BreathePageView()
{
InitializeComponent();
// 若后续需要生命周期 Hook,可在此处使用 WhenActivated
this.WhenActivated(_ => { });
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

182
AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml

@ -0,0 +1,182 @@
<reactive:ReactiveUserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:AuroraDesk.Presentation.ViewModels.Pages"
xmlns:avaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:attached="using:AuroraDesk.Presentation.Attached"
xmlns:reactive="using:ReactiveUI.Avalonia"
mc:Ignorable="d"
d:DesignWidth="1200"
d:DesignHeight="800"
x:Class="AuroraDesk.Presentation.Views.Pages.FileExplorerPageView"
x:DataType="vm:FileExplorerPageViewModel">
<Design.DataContext>
<vm:FileExplorerPageViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*">
<!-- 顶部目录选择区 -->
<Border Grid.Row="0"
Background="{StaticResource BackgroundLight}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="0,0,0,1"
Padding="20">
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="16">
<StackPanel Grid.Column="0" Spacing="10">
<TextBlock Text="文件目录浏览"
FontSize="24"
FontWeight="Bold"
Foreground="{StaticResource TextPrimary}"/>
<TextBlock Text="选择一个目录,左侧显示文件列表,右侧使用 AvaloniaEdit 预览文件内容"
FontSize="14"
Foreground="{StaticResource SecondaryGrayDark}"/>
<TextBlock Text="{Binding StatusMessage}"
FontSize="13"
Foreground="{StaticResource TextSecondary}"/>
</StackPanel>
<StackPanel Grid.Column="1"
Orientation="Horizontal"
Spacing="10"
VerticalAlignment="Center">
<TextBox Width="360"
Text="{Binding SelectedDirectory}"
IsReadOnly="True"
Watermark="请选择目录"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="10"
VerticalAlignment="Center">
<Button Content="选择目录"
Command="{Binding BrowseDirectoryCommand}"
HorizontalAlignment="Right"/>
<Button Content="刷新列表"
Command="{Binding RefreshDirectoryCommand}"
HorizontalAlignment="Right"/>
</StackPanel>
</Grid>
</Border>
<!-- 下方左右布局 -->
<Grid Grid.Row="1" ColumnDefinitions="320,*" ColumnSpacing="12" Margin="12">
<!-- 左侧:文件树 -->
<Border Background="{StaticResource BackgroundWhite}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="10"
Padding="16">
<Grid RowDefinitions="Auto,Auto,Auto,*" RowSpacing="12">
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center" Spacing="8">
<TextBlock Text="文件树"
FontSize="18"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"/>
<Border Background="{StaticResource BackgroundLight}"
CornerRadius="12"
Padding="10,4">
<TextBlock Text="{Binding TotalFileCount, StringFormat='文件数:{0}'}"
FontSize="12"
Foreground="{StaticResource TextSecondary}"/>
</Border>
</StackPanel>
<TextBlock Grid.Row="1"
Text="展开左侧树并点击文件,即可在右侧预览内容"
FontSize="13"
Foreground="{StaticResource SecondaryGrayDark}"/>
<TextBox Grid.Row="2"
Height="36"
CornerRadius="8"
Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}"
Watermark="搜索文件或文件夹"
VerticalAlignment="Center"/>
<TreeView Grid.Row="3"
ItemsSource="{Binding FilteredTreeItems}"
SelectedItem="{Binding SelectedNode}"
Background="{StaticResource BackgroundLight}"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="Margin" Value="0,2,0,2"/>
<Setter Property="Padding" Value="6,4"/>
</Style>
</TreeView.Styles>
<TreeView.DataTemplates>
<TreeDataTemplate DataType="vm:FileExplorerPageViewModel+FileTreeNode"
ItemsSource="{Binding Children}">
<StackPanel Spacing="2">
<TextBlock Text="{Binding Name}"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding SizeDisplay}"
FontSize="11"
Foreground="{StaticResource SecondaryGrayDark}"/>
</StackPanel>
</TreeDataTemplate>
</TreeView.DataTemplates>
</TreeView>
</Grid>
</Border>
<!-- 右侧:文件内容展示 -->
<Border Grid.Column="1"
Background="{StaticResource BackgroundWhite}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="10"
Padding="16">
<Grid RowDefinitions="Auto,*" RowSpacing="12">
<StackPanel Orientation="Vertical" Spacing="6">
<TextBlock Text="{Binding SelectedFileName}"
FontSize="20"
FontWeight="SemiBold"
Foreground="{StaticResource TextPrimary}"/>
<TextBlock Text="{Binding SelectedFileInfo}"
FontSize="13"
Foreground="{StaticResource SecondaryGrayDark}"/>
<StackPanel Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Text="语法高亮:"
FontSize="13"
Foreground="{StaticResource SecondaryGrayDark}"
VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding AvailableHighlightLanguages}"
SelectedItem="{Binding SelectedHighlightLanguage}"
MinWidth="150"
MaxWidth="200"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
<Border Grid.Row="1"
Background="{StaticResource BackgroundLight}"
BorderBrush="{StaticResource BorderLight}"
BorderThickness="1"
CornerRadius="8"
Padding="0">
<avaloniaEdit:TextEditor attached:TextEditorAssist.Document="{Binding Document}"
IsReadOnly="True"
ShowLineNumbers="True"
FontFamily="Consolas, 'Courier New', monospace"
FontSize="14"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Visible"
Background="{StaticResource BackgroundWhite}"
Foreground="{StaticResource TextPrimary}"
attached:TextMateHelper.Language="{Binding ActiveHighlightLanguage}"
Margin="0"/>
</Border>
</Grid>
</Border>
</Grid>
</Grid>
</reactive:ReactiveUserControl>

22
AuroraDesk.Presentation/Views/Pages/FileExplorerPageView.axaml.cs

@ -0,0 +1,22 @@
using AuroraDesk.Presentation.ViewModels.Pages;
using Avalonia.Markup.Xaml;
using ReactiveUI.Avalonia;
namespace AuroraDesk.Presentation.Views.Pages;
/// <summary>
/// 文件目录浏览页面 View
/// </summary>
public partial class FileExplorerPageView : ReactiveUserControl<FileExplorerPageViewModel>
{
public FileExplorerPageView()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

1038
AuroraDesk.Presentation/Views/Pages/TcpClientPageView.axaml

File diff suppressed because it is too large

140
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<TcpClientPageViewModel>
{
private ContentControl? _sessionDetailContent;
private ListBox? _receivedMessagesListBox;
private TcpClientSessionViewModel? _currentSession;
private IDisposable? _messagesSubscription;
private IDisposable? _autoScrollSubscription;
public TcpClientPageView()
{
InitializeComponent();
this.WhenActivated(disposables =>
{
_sessionDetailContent ??= this.FindControl<ContentControl>("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<NotifyCollectionChangedEventHandler, NotifyCollectionChangedEventArgs>(
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<ListBox>()
.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);
}
}

16
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+)时触发大量异步操作

218
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日
- **调整内容**:

Loading…
Cancel
Save