返回提交历史
Modified
XFEToolBox.Core/Downloads/SoftwareCatalogContracts.cs
+2
-0
Modified
XFEToolBox/Resources/Style/StandardControlsStyle.xaml
+2
-0
Modified
XFEToolBox/Views/Pages/Admin/SoftwareManagementPage.xaml
+32
-5
Modified
XFEToolBox/Views/Pages/Admin/SoftwareManagementPage.xaml.cs
+116
-4
Modified
XFEToolBox/Views/Pages/DownloadInfoPage.xaml
+1
-1
Modified
XFEToolBox/Views/Pages/DownloadPage.xaml
+3
-0
XFEstudio/XFEToolBox
支持软件图标异步加载与ComboBox优化
- SoftwareCatalogRow 增加 IconSource 属性及异步加载方法,防止重复加载图标 - SoftwareManagementPage 实现异步图标加载,支持 base64 与网络图片,优先显示内置资源 - XAML 软件卡片优先显示 IconSource,无则显示首字母 - ComboBox 增加 TextSearch.TextPath,提升筛选与搜索体验 - FilterOption 和 ChannelModeOption 重写 ToString,优化下拉框显示 - 其他细节优化如事件绑定和资源路径适配
a669569
代码差异
6 个文件
+156
-10
@@ -27,6 +27,8 @@ public sealed class SoftwareDownloadChannel
27
27
28
28
public bool Enabled { get; set; } = true;
29
29
public int SortOrder { get; set; }
30
31
public override string ToString() => string.IsNullOrWhiteSpace(Name) ? Id : Name;
30
32
}
31
33
32
34
/// <summary>
@@ -252,6 +252,8 @@
252
252
<ContentPresenter Margin="{Binding Padding, RelativeSource={RelativeSource AncestorType=ComboBox}}" VerticalAlignment="Center"
253
253
Content="{Binding SelectionBoxItem, RelativeSource={RelativeSource AncestorType=ComboBox}}"
254
254
ContentTemplate="{Binding SelectionBoxItemTemplate, RelativeSource={RelativeSource AncestorType=ComboBox}}"
255
ContentTemplateSelector="{Binding ItemTemplateSelector, RelativeSource={RelativeSource AncestorType=ComboBox}}"
256
ContentStringFormat="{Binding SelectionBoxItemStringFormat, RelativeSource={RelativeSource AncestorType=ComboBox}}"
255
257
TextElement.Foreground="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ComboBox}}"/>
256
258
<Path Grid.Column="1" Width="8" Height="5" Data="M 0,0 L 4,4 L 8,0 Z" Stretch="Fill"
257
259
Fill="{DynamicResource MainColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
@@ -105,7 +105,8 @@
105
105
TextChanged="CatalogSearchTextBox_TextChanged"/>
106
106
<ComboBox x:Name="CategoryFilterBox" Grid.Column="2" Height="36" SelectionChanged="CatalogFilterBox_SelectionChanged"/>
107
107
<ComboBox x:Name="StatusFilterBox" Grid.Column="4" Height="36"
108
DisplayMemberPath="Text" SelectionChanged="CatalogFilterBox_SelectionChanged"/>
108
DisplayMemberPath="Text" TextSearch.TextPath="Text"
109
SelectionChanged="CatalogFilterBox_SelectionChanged"/>
109
110
<Button Grid.Column="6" Content="清除筛选" MinWidth="84" Click="ClearFiltersButton_Click"/>
110
111
</Grid>
111
112
@@ -123,7 +124,8 @@
123
124
<DataTemplate>
124
125
<Border Padding="14,12" Background="#FCFCFF" BorderBrush="#E3E3EF"
125
126
BorderThickness="1" CornerRadius="13" Cursor="Hand"
126
Tag="{Binding}" MouseLeftButtonUp="SoftwareCard_MouseLeftButtonUp">
127
Tag="{Binding}" Loaded="SoftwareCard_Loaded"
128
MouseLeftButtonUp="SoftwareCard_MouseLeftButtonUp">
127
129
<Grid>
128
130
<Grid.ColumnDefinitions>
129
131
<ColumnDefinition Width="46"/><ColumnDefinition Width="14"/>
@@ -133,9 +135,34 @@
133
135
</Grid.ColumnDefinitions>
134
136
<Border Width="46" Height="46" CornerRadius="14" Background="#EEEEFC"
135
137
BorderBrush="#E0E0F2" BorderThickness="1">
136
<TextBlock Text="{Binding Initial}" Foreground="{DynamicResource MainColor}"
137
FontSize="16" FontWeight="SemiBold"
138
HorizontalAlignment="Center" VerticalAlignment="Center"/>
138
<Grid>
139
<TextBlock Text="{Binding Initial}" Foreground="{DynamicResource MainColor}"
140
FontSize="16" FontWeight="SemiBold"
141
HorizontalAlignment="Center" VerticalAlignment="Center">
142
<TextBlock.Style>
143
<Style TargetType="TextBlock">
144
<Setter Property="Visibility" Value="Collapsed"/>
145
<Style.Triggers>
146
<DataTrigger Binding="{Binding IconSource}" Value="{x:Null}">
147
<Setter Property="Visibility" Value="Visible"/>
148
</DataTrigger>
149
</Style.Triggers>
150
</Style>
151
</TextBlock.Style>
152
</TextBlock>
153
<Image Source="{Binding IconSource}" Width="31" Height="31" Stretch="Uniform">
154
<Image.Style>
155
<Style TargetType="Image">
156
<Setter Property="Visibility" Value="Visible"/>
157
<Style.Triggers>
158
<DataTrigger Binding="{Binding IconSource}" Value="{x:Null}">
159
<Setter Property="Visibility" Value="Collapsed"/>
160
</DataTrigger>
161
</Style.Triggers>
162
</Style>
163
</Image.Style>
164
</Image>
165
</Grid>
139
166
</Border>
140
167
<StackPanel Grid.Column="2" VerticalAlignment="Center">
141
168
<StackPanel Orientation="Horizontal">
@@ -2,11 +2,14 @@ using System.Collections.ObjectModel;
2
2
using System.ComponentModel;
3
3
using System.IO;
4
4
using System.Net;
5
using System.Net.Http;
5
6
using System.Runtime.CompilerServices;
6
7
using System.Windows;
7
8
using System.Windows.Controls;
8
9
using System.Windows.Data;
9
10
using System.Windows.Input;
11
using System.Windows.Media;
12
using System.Windows.Media.Imaging;
10
13
using Microsoft.Win32;
11
14
using XFEToolBox.Client.Utilities;
12
15
using XFEToolBox.Client.Utilities.Server;
@@ -18,6 +21,8 @@ public partial class SoftwareManagementPage : Page
18
21
{
19
22
public static SoftwareManagementPage Current { get; } = new();
20
23
24
private static readonly HttpClient IconClient = CreateIconClient();
25
private static readonly SemaphoreSlim IconLoadGate = new(4);
21
26
private readonly ObservableCollection<SoftwareCatalogItem> _software = [];
22
27
private readonly ObservableCollection<SoftwareCatalogRow> _softwareRows = [];
23
28
private readonly ObservableCollection<EditableChannel> _channels = [];
@@ -95,7 +100,7 @@ public partial class SoftwareManagementPage : Page
95
100
foreach (var item in response.Result.OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase))
96
101
{
97
102
_software.Add(item);
98
_softwareRows.Add(new SoftwareCatalogRow(item));
103
_softwareRows.Add(new SoftwareCatalogRow(item, GetBundledIcon(item.Id)));
99
104
}
100
105
101
106
RebuildSuggestionSources();
@@ -237,6 +242,87 @@ public partial class SoftwareManagementPage : Page
237
242
}
238
243
}
239
244
245
private async void SoftwareCard_Loaded(object sender, RoutedEventArgs e)
246
{
247
if (sender is Border { DataContext: SoftwareCatalogRow row } && row.TryBeginIconLoad())
248
await LoadConfiguredIconAsync(row);
249
}
250
251
private static async Task LoadConfiguredIconAsync(SoftwareCatalogRow row)
252
{
253
await IconLoadGate.WaitAsync();
254
try
255
{
256
var icon = await ReadIconAsync(row.Item.IconUrl);
257
if (icon is not null)
258
row.IconSource = icon;
259
}
260
catch
261
{
262
// 配置图标不可用时继续显示内置图标或首字母占位。
263
}
264
finally
265
{
266
IconLoadGate.Release();
267
}
268
}
269
270
private static async Task<ImageSource?> ReadIconAsync(string value)
271
{
272
byte[] bytes;
273
if (value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
274
{
275
var separator = value.IndexOf(',');
276
if (separator < 0)
277
return null;
278
bytes = Convert.FromBase64String(value[(separator + 1)..]);
279
}
280
else
281
{
282
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
283
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
284
return null;
285
bytes = await IconClient.GetByteArrayAsync(uri);
286
}
287
288
if (bytes.Length == 0 || bytes.Length > 1024 * 1024)
289
return null;
290
291
using var stream = new MemoryStream(bytes);
292
var image = new BitmapImage();
293
image.BeginInit();
294
image.CacheOption = BitmapCacheOption.OnLoad;
295
image.StreamSource = stream;
296
image.EndInit();
297
image.Freeze();
298
return image;
299
}
300
301
private static ImageSource? GetBundledIcon(string id)
302
{
303
var resource = id.ToLowerInvariant() switch
304
{
305
"steam" => "/Resources/Image/DownloadImage/steam_logo.png",
306
"watt-toolkit" or "steampp" => "/Resources/Image/DownloadImage/steampp.png",
307
"visual-studio" => "/Resources/Image/DownloadImage/visual_studio.png",
308
"cheat-engine" => "/Resources/Image/DownloadImage/cheat_engine.png",
309
_ => null
310
};
311
if (resource is null)
312
return null;
313
314
var image = new BitmapImage(new Uri($"pack://application:,,,{resource}", UriKind.Absolute));
315
image.Freeze();
316
return image;
317
}
318
319
private static HttpClient CreateIconClient()
320
{
321
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
322
client.DefaultRequestHeaders.UserAgent.ParseAdd("XFEToolBox/1.0");
323
return client;
324
}
325
240
326
private void NewButton_Click(object sender, RoutedEventArgs e)
241
327
{
242
328
_isCreating = true;
@@ -704,12 +790,32 @@ public partial class SoftwareManagementPage : Page
704
790
Channels = _channels.Select((channel, index) => channel.ToContract(index)).ToArray()
705
791
};
706
792
707
private sealed class SoftwareCatalogRow(SoftwareCatalogItem item)
793
private sealed class SoftwareCatalogRow(SoftwareCatalogItem item, ImageSource? iconSource) : INotifyPropertyChanged
708
794
{
795
private int _iconLoadStarted;
796
private ImageSource? _iconSource = iconSource;
797
709
798
public SoftwareCatalogItem Item { get; } = item;
710
799
public string Initial => string.IsNullOrWhiteSpace(Item.Name) ? "?" : Item.Name.Trim()[..1].ToUpperInvariant();
711
800
public string ChannelSummary => $"{Item.GetEffectiveChannels().Length} 个渠道";
712
801
public string FeaturedText => Item.Featured ? "精选" : "普通";
802
803
public ImageSource? IconSource
804
{
805
get => _iconSource;
806
set
807
{
808
if (ReferenceEquals(_iconSource, value))
809
return;
810
_iconSource = value;
811
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IconSource)));
812
}
813
}
814
815
public bool TryBeginIconLoad() =>
816
!string.IsNullOrWhiteSpace(Item.IconUrl) && Interlocked.Exchange(ref _iconLoadStarted, 1) == 0;
817
818
public event PropertyChangedEventHandler? PropertyChanged;
713
819
}
714
820
715
821
private sealed class EditableChannel : INotifyPropertyChanged
@@ -798,6 +904,12 @@ public partial class SoftwareManagementPage : Page
798
904
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
799
905
}
800
906
801
private sealed record FilterOption(string Text, string Value);
802
private sealed record ChannelModeOption(string Name, SoftwareDownloadMode Value);
907
private sealed record FilterOption(string Text, string Value)
908
{
909
public override string ToString() => Text;
910
}
911
private sealed record ChannelModeOption(string Name, SoftwareDownloadMode Value)
912
{
913
public override string ToString() => Name;
914
}
803
915
}
@@ -45,7 +45,7 @@
45
45
<TextBlock Text="可选择不同来源" Foreground="#A0A0B1" FontSize="8.5" Margin="0,2,0,0"/>
46
46
</StackPanel>
47
47
<ComboBox Grid.Column="1" Height="38" ItemsSource="{Binding Channels}" SelectedItem="{Binding SelectedChannel}"
48
DisplayMemberPath="Name"/>
48
DisplayMemberPath="Name" TextSearch.TextPath="Name"/>
49
49
</Grid>
50
50
</StackPanel>
51
51
</Border>
@@ -72,6 +72,9 @@
72
72
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="24"/></Grid.ColumnDefinitions>
73
73
<ContentPresenter Margin="{TemplateBinding Padding}" VerticalAlignment="Center" HorizontalAlignment="Left"
74
74
Content="{Binding SelectionBoxItem, RelativeSource={RelativeSource AncestorType=ComboBox}}"
75
ContentTemplate="{Binding SelectionBoxItemTemplate, RelativeSource={RelativeSource AncestorType=ComboBox}}"
76
ContentTemplateSelector="{Binding ItemTemplateSelector, RelativeSource={RelativeSource AncestorType=ComboBox}}"
77
ContentStringFormat="{Binding SelectionBoxItemStringFormat, RelativeSource={RelativeSource AncestorType=ComboBox}}"
75
78
TextElement.Foreground="{TemplateBinding Foreground}"/>
76
79
<Path x:Name="Arrow" Grid.Column="1" Width="8" Height="5" Stretch="Fill" Fill="#F4F4FF"
77
80
HorizontalAlignment="Center" VerticalAlignment="Center" RenderTransformOrigin="0.5,0.5"