21 changed files with 4077 additions and 17 deletions
@ -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": {} |
|||
} |
|||
|
|||
@ -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": {} |
|||
} |
|||
@ -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": [] |
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
@ -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"; |
|||
} |
|||
|
|||
@ -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(); |
|||
} |
|||
} |
|||
|
|||
@ -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); |
|||
|
|||
@ -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> |
|||
|
|||
@ -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); |
|||
} |
|||
} |
|||
|
|||
@ -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> |
|||
|
|||
@ -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); |
|||
} |
|||
} |
|||
|
|||
File diff suppressed because it is too large
@ -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); |
|||
} |
|||
} |
|||
|
|||
Loading…
Reference in new issue