using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using XFEToolBox.Client.Utilities.Helpers;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Client.Models.Server;
using XFEToolBox.Client.Models;
using XFEToolBox.Client.Utilities;
using XFEToolBox.WpfCore.Controls;
using XFEToolBox.Client.Views.Pages;
using XFEToolBox.Client.Views.Windows;
using XFEToolBox.Client.Profiles.CrossVersionProfiles;
namespace XFEToolBox.Client.ViewModel.Pages;
public partial class MainPageViewModel : ObservableObject
{
private const int MaximumAttempts = 3;
private const int CoverDownloadAttempts = 2;
private const int PopularVideoCount = 1;
private const int LatestVideoCount = 1;
private const int TutorialVideoCount = 1;
private const int ExpectedCarouselItemCount = PopularVideoCount + LatestVideoCount + TutorialVideoCount;
private const int MaximumVisibleRecentItems = 4;
private Task? loadingTask;
private readonly DispatcherTimer adminRefreshTimer;
private bool isAdminOverviewLoading;
private bool hasAdminOverviewSnapshot;
private bool hasRecentUsageSnapshot;
public MainPage MainPage { get; }
public MainPageViewModel(MainPage mainPage)
{
MainPage = mainPage;
MainPage.Loaded += MainPage_Loaded;
MainPage.Unloaded += MainPage_Unloaded;
ClientSession.SessionChanged += ClientSession_SessionChanged;
RecentUsageService.Changed += RecentUsageService_Changed;
PinnedItemService.Changed += DashboardData_Changed;
ActivityCenterService.Changed += DashboardData_Changed;
if (Application.Current is App app)
app.GlobalHotkeyStatusChanged += (_, _) => MainPage.Dispatcher.InvokeAsync(
() => OnPropertyChanged(nameof(LauncherHotkeyHint)));
adminRefreshTimer = new DispatcherTimer(DispatcherPriority.Background, MainPage.Dispatcher)
{
Interval = TimeSpan.FromSeconds(2)
};
adminRefreshTimer.Tick += AdminRefreshTimer_Tick;
}
[ObservableProperty] private Visibility adminServerPanelVisibility = Visibility.Collapsed;
[ObservableProperty] private string adminServerStatus = "XFEToolBoxServer";
[ObservableProperty] private string adminCpuText = "--";
[ObservableProperty] private string adminCpuDetail = "--";
[ObservableProperty] private string adminMemoryText = "--";
[ObservableProperty] private string adminMemoryDetail = "--";
[ObservableProperty] private string adminUsersText = "--";
[ObservableProperty] private string adminUsersDetail = "--";
[ObservableProperty] private string adminContentText = "--";
[ObservableProperty] private string adminContentDetail = "--";
[ObservableProperty] private string adminStorageText = "--";
[ObservableProperty] private string adminStorageDetail = "--";
[ObservableProperty] private string adminUptimeText = "--";
[ObservableProperty] private string adminUptimeDetail = "--";
[ObservableProperty] private string adminUpdatedText = string.Empty;
[ObservableProperty] private Visibility recentItemsVisibility = Visibility.Collapsed;
[ObservableProperty] private Visibility recentEmptyVisibility = Visibility.Visible;
[ObservableProperty] private string recentUsageCountText = "0 项";
[ObservableProperty] private Visibility quickAccessVisibility = Visibility.Collapsed;
[ObservableProperty] private Visibility activitySectionVisibility = Visibility.Collapsed;
[ObservableProperty] private Visibility latestToolsVisibility = Visibility.Collapsed;
[ObservableProperty] private string activityCountText = "暂无活动";
public ObservableCollection<RecentUsageCardViewModel> RecentItems { get; } = [];
public ObservableCollection<LauncherItemViewModel> QuickAccessItems { get; } = [];
public ObservableCollection<LauncherItemViewModel> RecentlyUpdatedTools { get; } = [];
public ObservableCollection<ActivityItem> ActivityItems { get; } = [];
public string LauncherHotkeyHint => SystemProfile.LauncherHotkeyEnabled
? SystemProfile.LauncherHotkey.Replace("+", " + ", StringComparison.Ordinal)
: "快捷键已禁用";
private async void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
if (!hasRecentUsageSnapshot)
RefreshRecentUsage();
await RefreshDashboardAsync();
if (!MainPage.mainCarousel.HasItems)
_ = MainPage.Dispatcher.InvokeAsync(
async () => await ReloadAsync(),
DispatcherPriority.ContextIdle);
await LoadAdminOverviewAsync();
if (ClientSession.IsAdministrator) adminRefreshTimer.Start();
}
private void MainPage_Unloaded(object sender, RoutedEventArgs e) => adminRefreshTimer.Stop();
private async void AdminRefreshTimer_Tick(object? sender, EventArgs e) => await LoadAdminOverviewAsync();
private void ClientSession_SessionChanged(object? sender, EventArgs e) => MainPage.Dispatcher.InvokeAsync(async () =>
{
await LoadAdminOverviewAsync();
if (ClientSession.IsAdministrator && MainPage.IsVisible) adminRefreshTimer.Start();
else adminRefreshTimer.Stop();
});
private void RecentUsageService_Changed(object? sender, EventArgs e) =>
MainPage.Dispatcher.InvokeAsync(async () =>
{
RefreshRecentUsage();
await RefreshDashboardAsync();
});
private void DashboardData_Changed(object? sender, EventArgs e) =>
MainPage.Dispatcher.InvokeAsync(RefreshDashboardAsync);
public async Task OpenRecentItemAsync(RecentUsageCardViewModel card)
{
if (!card.IsEnabled || MainWindow.Current is null) return;
card.IsEnabled = false;
try
{
switch (card.Entry.Kind)
{
case RecentUsageKind.Tool:
MainWindow.Current.ViewModel.NavigateToPageCommand.Execute("tool");
await ToolBoxPage.Current.OpenToolByIdAsync(card.Entry.TargetId);
break;
case RecentUsageKind.Software:
MainWindow.Current.ViewModel.NavigateToPageCommand.Execute("download");
await DownloadPage.Current.OpenSoftwareByIdAsync(card.Entry.TargetId);
break;
case RecentUsageKind.Project:
await ToolWorkshopService.OpenProjectAsync(card.Entry.TargetId);
break;
}
}
finally
{
card.IsEnabled = true;
}
}
public void RemoveRecentItem(RecentUsageCardViewModel card) =>
RecentUsageService.Remove(card.Entry.Kind, card.Entry.TargetId);
public void ClearRecentUsage() => RecentUsageService.Clear();
public void OpenToolBox() => MainWindow.Current?.ViewModel.NavigateToPageCommand.Execute("tool");
public void OpenCommandPalette(string? initialQuery = null) =>
(Application.Current as App)?.ShowCommandPalette(initialQuery);
public async Task ExecuteLauncherItemAsync(LauncherItemViewModel item) => await item.ExecuteAsync();
public bool TogglePinned(LauncherItemViewModel item) => item.TogglePinned();
private void RefreshRecentUsage()
{
var entries = RecentUsageService.GetRecent()
.Where(entry => entry.Kind is RecentUsageKind.Tool or RecentUsageKind.Software or RecentUsageKind.Project)
.Take(MaximumVisibleRecentItems)
.ToArray();
var existing = RecentItems.ToDictionary(
item => CreateRecentUsageKey(item.Entry),
StringComparer.OrdinalIgnoreCase);
var recent = entries.Select(entry =>
{
var key = CreateRecentUsageKey(entry);
return existing.TryGetValue(key, out var card) && EntriesEquivalent(card.Entry, entry)
? card
: new RecentUsageCardViewModel(entry);
}).ToArray();
for (var index = 0; index < recent.Length; index++)
{
if (index >= RecentItems.Count)
RecentItems.Add(recent[index]);
else if (!ReferenceEquals(RecentItems[index], recent[index]))
RecentItems[index] = recent[index];
}
while (RecentItems.Count > recent.Length)
RecentItems.RemoveAt(RecentItems.Count - 1);
hasRecentUsageSnapshot = true;
RecentItemsVisibility = recent.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
RecentEmptyVisibility = recent.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
RecentUsageCountText = $"{recent.Length} 项";
}
private async Task RefreshDashboardAsync()
{
var quickAccess = (await LauncherService.GetQuickAccessAsync())
.Select(item => new LauncherItemViewModel(item))
.ToArray();
var latestTools = (await LauncherService.GetRecentlyUpdatedToolsAsync())
.Select(item => new LauncherItemViewModel(item))
.ToArray();
var activities = ActivityCenterService.GetSnapshot(4);
ReplaceItems(QuickAccessItems, quickAccess);
ReplaceItems(RecentlyUpdatedTools, latestTools);
ReplaceItems(ActivityItems, activities);
QuickAccessVisibility = QuickAccessItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
LatestToolsVisibility = RecentlyUpdatedTools.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
ActivitySectionVisibility = ActivityItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
ActivityCountText = ActivityCenterService.ActiveCount > 0
? $"{ActivityCenterService.ActiveCount} 项进行中"
: ActivityItems.Count > 0 ? "最近活动" : "暂无活动";
}
private static void ReplaceItems<T>(ObservableCollection<T> target, IReadOnlyList<T> source)
{
target.Clear();
foreach (var item in source) target.Add(item);
}
private static string CreateRecentUsageKey(RecentUsageEntry entry) =>
$"{entry.Kind}:{entry.TargetId}";
private static bool EntriesEquivalent(RecentUsageEntry left, RecentUsageEntry right) =>
left.Kind == right.Kind
&& string.Equals(left.TargetId, right.TargetId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.Name, right.Name, StringComparison.Ordinal)
&& string.Equals(left.Description, right.Description, StringComparison.Ordinal)
&& string.Equals(left.Detail, right.Detail, StringComparison.Ordinal)
&& string.Equals(left.IconReference, right.IconReference, StringComparison.Ordinal)
&& left.LastUsedAtUtc == right.LastUsedAtUtc;
private async Task LoadAdminOverviewAsync()
{
AdminServerPanelVisibility = ClientSession.IsAdministrator ? Visibility.Visible : Visibility.Collapsed;
if (!ClientSession.IsAdministrator || isAdminOverviewLoading) return;
isAdminOverviewLoading = true;
try
{
var response = await ClientSession.Requester.Request<AdminOverview>("adminOverview");
if (response.StatusCode != HttpStatusCode.OK || response.Result is null)
{
if (!hasAdminOverviewSnapshot)
AdminServerStatus = string.IsNullOrWhiteSpace(response.Message) ? "服务器状态不可用" : response.Message;
return;
}
var overview = response.Result;
AdminServerStatus = overview.Status == "running" ? $"{overview.ServerName} · 运行中" : overview.Status;
AdminCpuText = $"{overview.CpuUsagePercent:F1}%";
AdminCpuDetail = $"{overview.ProcessorCount} 个逻辑核心";
AdminMemoryText = $"{overview.MemoryUsagePercent:F1}%";
AdminMemoryDetail = $"已用 {FormatBytes(overview.UsedMemoryBytes)} / {FormatBytes(overview.TotalMemoryBytes)}\n进程 {FormatBytes(overview.WorkingSetBytes)} · 可用 {FormatBytes(overview.AvailableMemoryBytes)}";
AdminUsersText = $"{overview.UserCount} 个用户";
AdminUsersDetail = $"{overview.ActiveSessionCount} 个有效登录会话";
AdminContentText = $"{overview.PublishedPackageCount} 工具 · {overview.PublishedSoftwareCount} 软件";
AdminContentDetail = $"共 {overview.PackageCount} 个工具版本 · {overview.SoftwareCount} 个软件";
AdminStorageText = FormatBytes(overview.StorageBytes);
AdminStorageDetail = $"软件文件占用 {FormatBytes(overview.SoftwareStorageBytes)}";
AdminUptimeText = FormatDuration(overview.UptimeSeconds);
AdminUptimeDetail = $"服务器时间 {overview.Utc.ToLocalTime():yyyy-MM-dd HH:mm:ss}";
AdminUpdatedText = $"运行 {FormatDuration(overview.UptimeSeconds)} · {overview.Utc.ToLocalTime():HH:mm:ss} 更新";
hasAdminOverviewSnapshot = true;
}
catch (Exception exception)
{
if (!hasAdminOverviewSnapshot) AdminServerStatus = $"读取失败:{exception.Message}";
}
finally { isAdminOverviewLoading = false; }
}
public Task ReloadAsync()
{
if (loadingTask is { IsCompleted: false })
return loadingTask;
loadingTask = LoadCarouselAsync();
return loadingTask;
}
private async Task LoadCarouselAsync()
{
var carousel = MainPage.mainCarousel;
carousel.IsLoading = true;
carousel.CanRetry = false;
carousel.StatusMessage = "正在获取 B 站热门与最新视频…";
try
{
var groups = await Task.WhenAll(
LoadVideoGroupAsync(
"B站热门",
PopularVideoCount,
() => BilibiliHelper.GetPopularVideoListAsync(8)),
LoadVideoGroupAsync(
"我的最新",
LatestVideoCount,
() => BilibiliHelper.GetLatestCreatorVideoListAsync(8)),
LoadVideoGroupAsync(
"芝士 C#",
TutorialVideoCount,
() => BilibiliHelper.GetSeasonVideoListAsync(pageSize: 10)));
foreach (var group in groups.Where(group => group.Videos.Count < group.RequiredCount))
{
Debug.WriteLine(
$"轮播分组“{group.Badge}”仅获取到 {group.Videos.Count}/{group.RequiredCount} 项:{group.LastException?.Message}");
}
var candidates = ComposeOrderedVideos(groups);
if (candidates.Count == 0)
throw new HttpRequestException("Bilibili 内容源暂时没有返回可展示的视频");
var downloadedCovers = await Task.WhenAll(candidates.Select(DownloadCoverAsync));
var carouselItems = new List<CarouselImageItem>(downloadedCovers.Length);
foreach (var downloadedCover in downloadedCovers.OfType<DownloadedCover>())
{
var image = CreateBitmapImage(downloadedCover.ImageBytes);
var video = downloadedCover.Candidate.Video;
var videoUrl = $"https://www.bilibili.com/video/{video.Bvid}";
carouselItems.Add(new CarouselImageItem
{
Image = image,
Title = video.Title,
Badge = downloadedCover.Candidate.Badge,
Action = () => OpenExternalLink(videoUrl)
});
}
if (carouselItems.Count == 0)
throw new HttpRequestException("轮播封面图片全部下载失败");
carousel.SetItems(carouselItems);
carousel.StatusMessage = carouselItems.Count == ExpectedCarouselItemCount
? string.Empty
: $"已加载 {carouselItems.Count}/{ExpectedCarouselItemCount} 项内容";
carousel.CanRetry = carouselItems.Count < ExpectedCarouselItemCount;
}
catch (Exception exception)
{
Debug.WriteLine($"主页轮播内容加载失败:{exception}");
carousel.StatusMessage = "网络连接失败,请稍后重新加载";
carousel.CanRetry = true;
}
finally
{
carousel.IsLoading = false;
}
}
private static async Task<VideoGroupResult> LoadVideoGroupAsync(
string badge,
int requiredCount,
Func<Task<IReadOnlyList<BilibiliVideoInfo>>> loadAsync)
{
IReadOnlyList<BilibiliVideoInfo> bestResult = [];
Exception? lastException = null;
for (var attempt = 1; attempt <= MaximumAttempts; attempt++)
{
try
{
var videos = await loadAsync();
if (videos.Count > bestResult.Count)
bestResult = videos;
if (bestResult.Count >= requiredCount)
break;
lastException = new HttpRequestException(
$"内容源仅返回 {bestResult.Count}/{requiredCount} 项视频");
}
catch (Exception exception)
{
lastException = exception;
}
if (attempt < MaximumAttempts)
await Task.Delay(TimeSpan.FromMilliseconds(750 * attempt));
}
return new VideoGroupResult(badge, requiredCount, bestResult, lastException);
}
private static IReadOnlyList<CarouselVideoCandidate> ComposeOrderedVideos(
IEnumerable<VideoGroupResult> groups)
{
var result = new List<CarouselVideoCandidate>(ExpectedCarouselItemCount);
var usedBvids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in groups)
{
var addedCount = 0;
foreach (var video in group.Videos)
{
if (!usedBvids.Add(video.Bvid))
continue;
result.Add(new CarouselVideoCandidate(video, group.Badge));
addedCount++;
if (addedCount >= group.RequiredCount)
break;
}
}
return result;
}
private static void OpenExternalLink(string url)
{
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
private static async Task<DownloadedCover?> DownloadCoverAsync(CarouselVideoCandidate candidate)
{
for (var attempt = 1; attempt <= CoverDownloadAttempts; attempt++)
{
try
{
var imageBytes = await BilibiliHelper.GetImageBytesAsync(candidate.Video.PictureUrl);
if (imageBytes.Length > 0)
return new DownloadedCover(candidate, imageBytes);
}
catch (Exception exception)
{
if (attempt == CoverDownloadAttempts)
Debug.WriteLine($"轮播封面下载失败({candidate.Video.Bvid}):{exception.Message}");
}
}
return null;
}
private static BitmapImage CreateBitmapImage(byte[] imageBytes)
{
using var imageStream = new MemoryStream(imageBytes, writable: false);
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.DecodePixelWidth = 1200;
image.StreamSource = imageStream;
image.EndInit();
image.Freeze();
return image;
}
private static string FormatBytes(long bytes) => bytes switch
{
>= 1024L * 1024 * 1024 => $"{bytes / (1024d * 1024 * 1024):F1} GB",
>= 1024L * 1024 => $"{bytes / (1024d * 1024):F0} MB",
>= 1024L => $"{bytes / 1024d:F0} KB",
_ => $"{bytes} B"
};
private static string FormatDuration(double seconds)
{
var value = TimeSpan.FromSeconds(Math.Max(0, seconds));
return value.TotalDays >= 1 ? $"{(int)value.TotalDays} 天 {value.Hours} 小时" : $"{value.Hours} 小时 {value.Minutes} 分";
}
private sealed record VideoGroupResult(
string Badge,
int RequiredCount,
IReadOnlyList<BilibiliVideoInfo> Videos,
Exception? LastException);
private sealed record CarouselVideoCandidate(BilibiliVideoInfo Video, string Badge);
private sealed record DownloadedCover(CarouselVideoCandidate Candidate, byte[] ImageBytes);
}
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Net;
using System.Windows;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using XFEToolBox.Client.Utilities.Helpers;
using XFEToolBox.Client.Utilities.Server;
using XFEToolBox.Client.Models.Server;
using XFEToolBox.Client.Models;
using XFEToolBox.Client.Utilities;
using XFEToolBox.WpfCore.Controls;
using XFEToolBox.Client.Views.Pages;
using XFEToolBox.Client.Views.Windows;
using XFEToolBox.Client.Profiles.CrossVersionProfiles;
namespace XFEToolBox.Client.ViewModel.Pages;
public partial class MainPageViewModel : ObservableObject
{
private const int MaximumAttempts = 3;
private const int CoverDownloadAttempts = 2;
private const int PopularVideoCount = 1;
private const int LatestVideoCount = 1;
private const int TutorialVideoCount = 1;
private const int ExpectedCarouselItemCount = PopularVideoCount + LatestVideoCount + TutorialVideoCount;
private const int MaximumVisibleRecentItems = 4;
private Task? loadingTask;
private readonly DispatcherTimer adminRefreshTimer;
private bool isAdminOverviewLoading;
private bool hasAdminOverviewSnapshot;
private bool hasRecentUsageSnapshot;
public MainPage MainPage { get; }
public MainPageViewModel(MainPage mainPage)
{
MainPage = mainPage;
MainPage.Loaded += MainPage_Loaded;
MainPage.Unloaded += MainPage_Unloaded;
ClientSession.SessionChanged += ClientSession_SessionChanged;
RecentUsageService.Changed += RecentUsageService_Changed;
PinnedItemService.Changed += DashboardData_Changed;
ActivityCenterService.Changed += DashboardData_Changed;
if (Application.Current is App app)
app.GlobalHotkeyStatusChanged += (_, _) => MainPage.Dispatcher.InvokeAsync(
() => OnPropertyChanged(nameof(LauncherHotkeyHint)));
adminRefreshTimer = new DispatcherTimer(DispatcherPriority.Background, MainPage.Dispatcher)
{
Interval = TimeSpan.FromSeconds(2)
};
adminRefreshTimer.Tick += AdminRefreshTimer_Tick;
}
[ObservableProperty] private Visibility adminServerPanelVisibility = Visibility.Collapsed;
[ObservableProperty] private string adminServerStatus = "XFEToolBoxServer";
[ObservableProperty] private string adminCpuText = "--";
[ObservableProperty] private string adminCpuDetail = "--";
[ObservableProperty] private string adminMemoryText = "--";
[ObservableProperty] private string adminMemoryDetail = "--";
[ObservableProperty] private string adminUsersText = "--";
[ObservableProperty] private string adminUsersDetail = "--";
[ObservableProperty] private string adminContentText = "--";
[ObservableProperty] private string adminContentDetail = "--";
[ObservableProperty] private string adminStorageText = "--";
[ObservableProperty] private string adminStorageDetail = "--";
[ObservableProperty] private string adminUptimeText = "--";
[ObservableProperty] private string adminUptimeDetail = "--";
[ObservableProperty] private string adminUpdatedText = string.Empty;
[ObservableProperty] private Visibility recentItemsVisibility = Visibility.Collapsed;
[ObservableProperty] private Visibility recentEmptyVisibility = Visibility.Visible;
[ObservableProperty] private string recentUsageCountText = "0 项";
[ObservableProperty] private Visibility quickAccessVisibility = Visibility.Collapsed;
[ObservableProperty] private Visibility activitySectionVisibility = Visibility.Collapsed;
[ObservableProperty] private Visibility latestToolsVisibility = Visibility.Collapsed;
[ObservableProperty] private string activityCountText = "暂无活动";
public ObservableCollection<RecentUsageCardViewModel> RecentItems { get; } = [];
public ObservableCollection<LauncherItemViewModel> QuickAccessItems { get; } = [];
public ObservableCollection<LauncherItemViewModel> RecentlyUpdatedTools { get; } = [];
public ObservableCollection<ActivityItem> ActivityItems { get; } = [];
public string LauncherHotkeyHint => SystemProfile.LauncherHotkeyEnabled
? SystemProfile.LauncherHotkey.Replace("+", " + ", StringComparison.Ordinal)
: "快捷键已禁用";
private async void MainPage_Loaded(object sender, System.Windows.RoutedEventArgs e)
{
if (!hasRecentUsageSnapshot)
RefreshRecentUsage();
await RefreshDashboardAsync();
if (!MainPage.mainCarousel.HasItems)
_ = MainPage.Dispatcher.InvokeAsync(
async () => await ReloadAsync(),
DispatcherPriority.ContextIdle);
await LoadAdminOverviewAsync();
if (ClientSession.IsAdministrator) adminRefreshTimer.Start();
}
private void MainPage_Unloaded(object sender, RoutedEventArgs e) => adminRefreshTimer.Stop();
private async void AdminRefreshTimer_Tick(object? sender, EventArgs e) => await LoadAdminOverviewAsync();
private void ClientSession_SessionChanged(object? sender, EventArgs e) => MainPage.Dispatcher.InvokeAsync(async () =>
{
await LoadAdminOverviewAsync();
if (ClientSession.IsAdministrator && MainPage.IsVisible) adminRefreshTimer.Start();
else adminRefreshTimer.Stop();
});
private void RecentUsageService_Changed(object? sender, EventArgs e) =>
MainPage.Dispatcher.InvokeAsync(async () =>
{
RefreshRecentUsage();
await RefreshDashboardAsync();
});
private void DashboardData_Changed(object? sender, EventArgs e) =>
MainPage.Dispatcher.InvokeAsync(RefreshDashboardAsync);
public async Task OpenRecentItemAsync(RecentUsageCardViewModel card)
{
if (!card.IsEnabled || MainWindow.Current is null) return;
card.IsEnabled = false;
try
{
switch (card.Entry.Kind)
{
case RecentUsageKind.Tool:
MainWindow.Current.ViewModel.NavigateToPageCommand.Execute("tool");
await ToolBoxPage.Current.OpenToolByIdAsync(card.Entry.TargetId);
break;
case RecentUsageKind.Software:
MainWindow.Current.ViewModel.NavigateToPageCommand.Execute("download");
await DownloadPage.Current.OpenSoftwareByIdAsync(card.Entry.TargetId);
break;
case RecentUsageKind.Project:
await ToolWorkshopService.OpenProjectAsync(card.Entry.TargetId);
break;
}
}
finally
{
card.IsEnabled = true;
}
}
public void RemoveRecentItem(RecentUsageCardViewModel card) =>
RecentUsageService.Remove(card.Entry.Kind, card.Entry.TargetId);
public void ClearRecentUsage() => RecentUsageService.Clear();
public void OpenToolBox() => MainWindow.Current?.ViewModel.NavigateToPageCommand.Execute("tool");
public void OpenCommandPalette(string? initialQuery = null) =>
(Application.Current as App)?.ShowCommandPalette(initialQuery);
public async Task ExecuteLauncherItemAsync(LauncherItemViewModel item) => await item.ExecuteAsync();
public bool TogglePinned(LauncherItemViewModel item) => item.TogglePinned();
private void RefreshRecentUsage()
{
var entries = RecentUsageService.GetRecent()
.Where(entry => entry.Kind is RecentUsageKind.Tool or RecentUsageKind.Software or RecentUsageKind.Project)
.Take(MaximumVisibleRecentItems)
.ToArray();
var existing = RecentItems.ToDictionary(
item => CreateRecentUsageKey(item.Entry),
StringComparer.OrdinalIgnoreCase);
var recent = entries.Select(entry =>
{
var key = CreateRecentUsageKey(entry);
return existing.TryGetValue(key, out var card) && EntriesEquivalent(card.Entry, entry)
? card
: new RecentUsageCardViewModel(entry);
}).ToArray();
for (var index = 0; index < recent.Length; index++)
{
if (index >= RecentItems.Count)
RecentItems.Add(recent[index]);
else if (!ReferenceEquals(RecentItems[index], recent[index]))
RecentItems[index] = recent[index];
}
while (RecentItems.Count > recent.Length)
RecentItems.RemoveAt(RecentItems.Count - 1);
hasRecentUsageSnapshot = true;
RecentItemsVisibility = recent.Length > 0 ? Visibility.Visible : Visibility.Collapsed;
RecentEmptyVisibility = recent.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
RecentUsageCountText = $"{recent.Length} 项";
}
private async Task RefreshDashboardAsync()
{
var quickAccess = (await LauncherService.GetQuickAccessAsync())
.Select(item => new LauncherItemViewModel(item))
.ToArray();
var latestTools = (await LauncherService.GetRecentlyUpdatedToolsAsync())
.Select(item => new LauncherItemViewModel(item))
.ToArray();
var activities = ActivityCenterService.GetSnapshot(4);
ReplaceItems(QuickAccessItems, quickAccess);
ReplaceItems(RecentlyUpdatedTools, latestTools);
ReplaceItems(ActivityItems, activities);
QuickAccessVisibility = QuickAccessItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
LatestToolsVisibility = RecentlyUpdatedTools.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
ActivitySectionVisibility = ActivityItems.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
ActivityCountText = ActivityCenterService.ActiveCount > 0
? $"{ActivityCenterService.ActiveCount} 项进行中"
: ActivityItems.Count > 0 ? "最近活动" : "暂无活动";
}
private static void ReplaceItems<T>(ObservableCollection<T> target, IReadOnlyList<T> source)
{
target.Clear();
foreach (var item in source) target.Add(item);
}
private static string CreateRecentUsageKey(RecentUsageEntry entry) =>
$"{entry.Kind}:{entry.TargetId}";
private static bool EntriesEquivalent(RecentUsageEntry left, RecentUsageEntry right) =>
left.Kind == right.Kind
&& string.Equals(left.TargetId, right.TargetId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(left.Name, right.Name, StringComparison.Ordinal)
&& string.Equals(left.Description, right.Description, StringComparison.Ordinal)
&& string.Equals(left.Detail, right.Detail, StringComparison.Ordinal)
&& string.Equals(left.IconReference, right.IconReference, StringComparison.Ordinal)
&& left.LastUsedAtUtc == right.LastUsedAtUtc;
private async Task LoadAdminOverviewAsync()
{
AdminServerPanelVisibility = ClientSession.IsAdministrator ? Visibility.Visible : Visibility.Collapsed;
if (!ClientSession.IsAdministrator || isAdminOverviewLoading) return;
isAdminOverviewLoading = true;
try
{
var response = await ClientSession.Requester.Request<AdminOverview>("adminOverview");
if (response.StatusCode != HttpStatusCode.OK || response.Result is null)
{
if (!hasAdminOverviewSnapshot)
AdminServerStatus = string.IsNullOrWhiteSpace(response.Message) ? "服务器状态不可用" : response.Message;
return;
}
var overview = response.Result;
AdminServerStatus = overview.Status == "running" ? $"{overview.ServerName} · 运行中" : overview.Status;
AdminCpuText = $"{overview.CpuUsagePercent:F1}%";
AdminCpuDetail = $"{overview.ProcessorCount} 个逻辑核心";
AdminMemoryText = $"{overview.MemoryUsagePercent:F1}%";
AdminMemoryDetail = $"已用 {FormatBytes(overview.UsedMemoryBytes)} / {FormatBytes(overview.TotalMemoryBytes)}\n进程 {FormatBytes(overview.WorkingSetBytes)} · 可用 {FormatBytes(overview.AvailableMemoryBytes)}";
AdminUsersText = $"{overview.UserCount} 个用户";
AdminUsersDetail = $"{overview.ActiveSessionCount} 个有效登录会话";
AdminContentText = $"{overview.PublishedPackageCount} 工具 · {overview.PublishedSoftwareCount} 软件";
AdminContentDetail = $"共 {overview.PackageCount} 个工具版本 · {overview.SoftwareCount} 个软件";
AdminStorageText = FormatBytes(overview.StorageBytes);
AdminStorageDetail = $"软件文件占用 {FormatBytes(overview.SoftwareStorageBytes)}";
AdminUptimeText = FormatDuration(overview.UptimeSeconds);
AdminUptimeDetail = $"服务器时间 {overview.Utc.ToLocalTime():yyyy-MM-dd HH:mm:ss}";
AdminUpdatedText = $"运行 {FormatDuration(overview.UptimeSeconds)} · {overview.Utc.ToLocalTime():HH:mm:ss} 更新";
hasAdminOverviewSnapshot = true;
}
catch (Exception exception)
{
if (!hasAdminOverviewSnapshot) AdminServerStatus = $"读取失败:{exception.Message}";
}
finally { isAdminOverviewLoading = false; }
}
public Task ReloadAsync()
{
if (loadingTask is { IsCompleted: false })
return loadingTask;
loadingTask = LoadCarouselAsync();
return loadingTask;
}
private async Task LoadCarouselAsync()
{
var carousel = MainPage.mainCarousel;
carousel.IsLoading = true;
carousel.CanRetry = false;
carousel.StatusMessage = "正在获取 B 站热门与最新视频…";
try
{
var groups = await Task.WhenAll(
LoadVideoGroupAsync(
"B站热门",
PopularVideoCount,
() => BilibiliHelper.GetPopularVideoListAsync(8)),
LoadVideoGroupAsync(
"我的最新",
LatestVideoCount,
() => BilibiliHelper.GetLatestCreatorVideoListAsync(8)),
LoadVideoGroupAsync(
"芝士 C#",
TutorialVideoCount,
() => BilibiliHelper.GetSeasonVideoListAsync(pageSize: 10)));
foreach (var group in groups.Where(group => group.Videos.Count < group.RequiredCount))
{
Debug.WriteLine(
$"轮播分组“{group.Badge}”仅获取到 {group.Videos.Count}/{group.RequiredCount} 项:{group.LastException?.Message}");
}
var candidates = ComposeOrderedVideos(groups);
if (candidates.Count == 0)
throw new HttpRequestException("Bilibili 内容源暂时没有返回可展示的视频");
var downloadedCovers = await Task.WhenAll(candidates.Select(DownloadCoverAsync));
var carouselItems = new List<CarouselImageItem>(downloadedCovers.Length);
foreach (var downloadedCover in downloadedCovers.OfType<DownloadedCover>())
{
var image = CreateBitmapImage(downloadedCover.ImageBytes);
var video = downloadedCover.Candidate.Video;
var videoUrl = $"https://www.bilibili.com/video/{video.Bvid}";
carouselItems.Add(new CarouselImageItem
{
Image = image,
Title = video.Title,
Badge = downloadedCover.Candidate.Badge,
Action = () => OpenExternalLink(videoUrl)
});
}
if (carouselItems.Count == 0)
throw new HttpRequestException("轮播封面图片全部下载失败");
carousel.SetItems(carouselItems);
carousel.StatusMessage = carouselItems.Count == ExpectedCarouselItemCount
? string.Empty
: $"已加载 {carouselItems.Count}/{ExpectedCarouselItemCount} 项内容";
carousel.CanRetry = carouselItems.Count < ExpectedCarouselItemCount;
}
catch (Exception exception)
{
Debug.WriteLine($"主页轮播内容加载失败:{exception}");
carousel.StatusMessage = "网络连接失败,请稍后重新加载";
carousel.CanRetry = true;
}
finally
{
carousel.IsLoading = false;
}
}
private static async Task<VideoGroupResult> LoadVideoGroupAsync(
string badge,
int requiredCount,
Func<Task<IReadOnlyList<BilibiliVideoInfo>>> loadAsync)
{
IReadOnlyList<BilibiliVideoInfo> bestResult = [];
Exception? lastException = null;
for (var attempt = 1; attempt <= MaximumAttempts; attempt++)
{
try
{
var videos = await loadAsync();
if (videos.Count > bestResult.Count)
bestResult = videos;
if (bestResult.Count >= requiredCount)
break;
lastException = new HttpRequestException(
$"内容源仅返回 {bestResult.Count}/{requiredCount} 项视频");
}
catch (Exception exception)
{
lastException = exception;
}
if (attempt < MaximumAttempts)
await Task.Delay(TimeSpan.FromMilliseconds(750 * attempt));
}
return new VideoGroupResult(badge, requiredCount, bestResult, lastException);
}
private static IReadOnlyList<CarouselVideoCandidate> ComposeOrderedVideos(
IEnumerable<VideoGroupResult> groups)
{
var result = new List<CarouselVideoCandidate>(ExpectedCarouselItemCount);
var usedBvids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var group in groups)
{
var addedCount = 0;
foreach (var video in group.Videos)
{
if (!usedBvids.Add(video.Bvid))
continue;
result.Add(new CarouselVideoCandidate(video, group.Badge));
addedCount++;
if (addedCount >= group.RequiredCount)
break;
}
}
return result;
}
private static void OpenExternalLink(string url)
{
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
private static async Task<DownloadedCover?> DownloadCoverAsync(CarouselVideoCandidate candidate)
{
for (var attempt = 1; attempt <= CoverDownloadAttempts; attempt++)
{
try
{
var imageBytes = await BilibiliHelper.GetImageBytesAsync(candidate.Video.PictureUrl);
if (imageBytes.Length > 0)
return new DownloadedCover(candidate, imageBytes);
}
catch (Exception exception)
{
if (attempt == CoverDownloadAttempts)
Debug.WriteLine($"轮播封面下载失败({candidate.Video.Bvid}):{exception.Message}");
}
}
return null;
}
private static BitmapImage CreateBitmapImage(byte[] imageBytes)
{
using var imageStream = new MemoryStream(imageBytes, writable: false);
var image = new BitmapImage();
image.BeginInit();
image.CacheOption = BitmapCacheOption.OnLoad;
image.DecodePixelWidth = 1200;
image.StreamSource = imageStream;
image.EndInit();
image.Freeze();
return image;
}
private static string FormatBytes(long bytes) => bytes switch
{
>= 1024L * 1024 * 1024 => $"{bytes / (1024d * 1024 * 1024):F1} GB",
>= 1024L * 1024 => $"{bytes / (1024d * 1024):F0} MB",
>= 1024L => $"{bytes / 1024d:F0} KB",
_ => $"{bytes} B"
};
private static string FormatDuration(double seconds)
{
var value = TimeSpan.FromSeconds(Math.Max(0, seconds));
return value.TotalDays >= 1 ? $"{(int)value.TotalDays} 天 {value.Hours} 小时" : $"{value.Hours} 小时 {value.Minutes} 分";
}
private sealed record VideoGroupResult(
string Badge,
int RequiredCount,
IReadOnlyList<BilibiliVideoInfo> Videos,
Exception? LastException);
private sealed record CarouselVideoCandidate(BilibiliVideoInfo Video, string Badge);
private sealed record DownloadedCover(CarouselVideoCandidate Candidate, byte[] ImageBytes);
}