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