返回提交历史
Added
.BuildValidation/RuntimeHostSmoke/Program.cs
+48
-0
Added
.BuildValidation/RuntimeHostSmoke/RuntimeHostSmoke.csproj
+14
-0
Added
XFEToolBox.Client.Core/Tools/ToolDataStore.cs
+299
-0
Modified
XFEToolBox.Core/Tools/ToolPackageManifest.cs
+10
-4
Modified
XFEToolBox/Profiles/CacheProfiles/AppCacheProfile.cs
+13
-1
Modified
XFEToolBox/Resources/Image/default_tool_icon.png
+0
-0
Modified
XFEToolBox/Resources/Style/StandardControlsStyle.xaml
+60
-4
Modified
XFEToolBox/Utilities/Helpers/BilibiliHelper.cs
+44
-13
Modified
XFEToolBox/Utilities/ToolProjectRunService.cs
+176
-8
Modified
XFEToolBox/Utilities/ToolProjectWorkspaceService.cs
+26
-8
Modified
XFEToolBox/ViewModel/Pages/MainPageViewModel.cs
+137
-48
Modified
XFEToolBox/Views/Controls/Carousel.xaml
+2
-2
Modified
XFEToolBox/Views/Controls/Carousel.xaml.cs
+18
-1
Modified
XFEToolBox/Views/Controls/CarouselImageItem.cs
+3
-0
Added
XFEToolBox/Views/Controls/DataGridAssist.cs
+188
-0
Modified
XFEToolBox/Views/Pages/DownloadPage.xaml
+6
-4
Modified
XFEToolBox/Views/Pages/DownloadPage.xaml.cs
+155
-24
Modified
XFEToolBox/Views/Pages/MainPage.xaml
+1
-1
Modified
XFEToolBox/Views/Pages/SettingPage.xaml
+1
-1
Modified
XFEToolBox/Views/Pages/ToolBoxPage.xaml
+49
-0
Modified
XFEToolBox/Views/Pages/ToolBoxPage.xaml.cs
+172
-13
Modified
XFEToolBox/Views/Windows/ToolCodeEditorWindow.xaml
+8
-6
Modified
XFEToolBox/Views/Windows/ToolCodeEditorWindow.xaml.cs
+2
-0
Modified
docs/tool-packages.md
+28
-4
XFEstudio/XFEToolBox
实现工具数据隔离存储与目录缓存机制
本次更新引入 ToolDataStore/ToolDataManager,实现工具级别的数据隔离存储,支持 JSON 读写、窗口状态自动保存、二进制路径获取及一键清除等功能。主界面和软件下载页新增本地目录缓存,支持离线浏览和后台自动刷新。工具窗口标题栏支持副标题,manifest.json 可配置。工具卡片右键菜单支持一键清除数据。新增 DataGridAssist 统一表格控件样式,优化 ComboBoxItem、DataGrid、软件卡片等 UI 细节。首页轮播支持多分组及更健壮的内容下载。同步更新模板和文档,新增 RuntimeHostSmoke 工程提升可测试性。其他细节优化包括状态提示、控件对齐、输入框尺寸等。
1b4e00a
代码差异
24 个文件
+1460
-142
@@ -0,0 +1,48 @@
1
using System.Reflection;
2
using System.IO;
3
using System.Text.Json;
4
using XFEToolBox.Core.Tools;
5
6
var workspacesRoot = args.Length > 0
7
? Path.GetFullPath(args[0])
8
: throw new ArgumentException("缺少工具工作区路径。");
9
var serviceType = typeof(XFEToolBox.Client.App).Assembly.GetType(
10
"XFEToolBox.Client.Utilities.ToolProjectRunService", true)!;
11
var method = serviceType.GetMethod("BuildAsync", BindingFlags.Public | BindingFlags.Static)
12
?? throw new MissingMethodException(serviceType.FullName, "BuildAsync");
13
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
14
var allSucceeded = true;
15
16
const string validationToolId = "local.storage-validation";
17
ToolDataManager.ClearToolData(validationToolId);
18
ToolDataStore.Initialize(validationToolId);
19
ToolDataStore.Write("settings", new StorageValidationSettings(42));
20
ToolDataStore.WriteWindowPlacement(new ToolWindowPlacement(
21
120, 80, 900, 620, "Minimized", "Maximized", true, DateTimeOffset.UtcNow));
22
var storedSettings = ToolDataStore.Read<StorageValidationSettings?>("settings");
23
var storedPlacement = ToolDataStore.ReadWindowPlacement();
24
if (storedSettings?.Value != 42 || storedPlacement is not { WasMinimized: true, LastVisibleState: "Maximized" })
25
throw new InvalidDataException("统一工具数据存储读写验证失败。");
26
ToolDataManager.ClearToolData(validationToolId);
27
Console.WriteLine("ToolDataStore: PASS - JSON、窗口状态与单工具清除均已验证。");
28
29
foreach (var workspace in Directory.EnumerateDirectories(workspacesRoot).OrderBy(Path.GetFileName))
30
{
31
var manifestPath = Path.Combine(workspace, "manifest.json");
32
if (!File.Exists(manifestPath)) continue;
33
var manifest = JsonSerializer.Deserialize<ToolPackageManifest>(await File.ReadAllTextAsync(manifestPath), options)
34
?? throw new InvalidDataException($"{manifestPath} 内容为空。");
35
var task = (Task)(method.Invoke(null, [workspace, manifest, CancellationToken.None])
36
?? throw new InvalidOperationException("运行服务未返回任务。"));
37
await task;
38
var result = task.GetType().GetProperty("Result")?.GetValue(task)
39
?? throw new InvalidOperationException("无法读取生成结果。");
40
var success = (bool)(result.GetType().GetProperty("Success")?.GetValue(result) ?? false);
41
var message = result.GetType().GetProperty("Message")?.GetValue(result)?.ToString() ?? string.Empty;
42
Console.WriteLine($"{Path.GetFileName(workspace)}: {(success ? "PASS" : "FAIL")} - {message}");
43
allSucceeded &= success;
44
}
45
46
return allSucceeded ? 0 : 1;
47
48
internal sealed record StorageValidationSettings(int Value);
@@ -0,0 +1,14 @@
1
<Project Sdk="Microsoft.NET.Sdk">
2
<PropertyGroup>
3
<OutputType>Exe</OutputType>
4
<TargetFramework>net10.0-windows10.0.17763.0</TargetFramework>
5
<ImplicitUsings>enable</ImplicitUsings>
6
<Nullable>enable</Nullable>
7
<UseWPF>true</UseWPF>
8
</PropertyGroup>
9
<ItemGroup>
10
<Reference Include="XFEToolBox"><HintPath>..\host-bin\Debug\net10.0-windows10.0.17763.0\XFEToolBox.dll</HintPath><Private>true</Private></Reference>
11
<Reference Include="XFEToolBox.Core"><HintPath>..\host-bin\Debug\net10.0\XFEToolBox.Core.dll</HintPath><Private>true</Private></Reference>
12
<Reference Include="XFEToolBox.Client.Core"><HintPath>..\host-bin\Debug\net10.0\XFEToolBox.Client.Core.dll</HintPath><Private>true</Private></Reference>
13
</ItemGroup>
14
</Project>
@@ -0,0 +1,299 @@
1
using System.Collections.Concurrent;
2
using System.Text.Json;
3
4
namespace XFEToolBox.Core.Tools;
5
6
/// <summary>
7
/// 工具宿主保存的窗口恢复信息。最小化状态会被记录,但下次启动时应恢复到
8
/// <see cref="LastVisibleState"/>,避免工具启动后不可见。
9
/// </summary>
10
public sealed record ToolWindowPlacement(
11
double Left,
12
double Top,
13
double Width,
14
double Height,
15
string State,
16
string LastVisibleState,
17
bool WasMinimized,
18
DateTimeOffset SavedAt);
19
20
/// <summary>
21
/// 当前工具进程的数据入口。工具宿主必须先以 manifest 中的工具 ID 调用
22
/// <see cref="Initialize"/>;工具代码随后只能通过当前上下文读写自己的数据。
23
/// </summary>
24
public static class ToolDataStore
25
{
26
private const string WindowPlacementKey = ".host/window-placement";
27
private static readonly object InitializationLock = new();
28
private static string? _currentToolId;
29
30
public static bool IsInitialized => _currentToolId is not null;
31
32
public static string CurrentToolId => _currentToolId
33
?? throw new InvalidOperationException("工具数据存储尚未初始化。请由 XFEToolBox 工具宿主先调用 ToolDataStore.Initialize。 ");
34
35
public static string DataDirectory => ToolDataManager.GetToolDataDirectory(CurrentToolId);
36
37
public static void Initialize(string toolId)
38
{
39
var validatedId = ToolDataManager.ValidateToolId(toolId);
40
lock (InitializationLock)
41
{
42
if (_currentToolId is null)
43
{
44
_currentToolId = validatedId;
45
return;
46
}
47
48
if (!string.Equals(_currentToolId, validatedId, StringComparison.OrdinalIgnoreCase))
49
throw new InvalidOperationException($"当前进程已绑定工具“{_currentToolId}”,不能切换到“{validatedId}”。");
50
}
51
}
52
53
public static T Read<T>(string key, T fallback = default!) =>
54
TryRead<T>(key, out var value) ? value! : fallback;
55
56
public static bool TryRead<T>(string key, out T? value)
57
{
58
try
59
{
60
value = ToolDataManager.ReadJson<T>(CurrentToolId, key);
61
return value is not null;
62
}
63
catch (IOException)
64
{
65
value = default;
66
return false;
67
}
68
catch (UnauthorizedAccessException)
69
{
70
value = default;
71
return false;
72
}
73
catch (JsonException)
74
{
75
value = default;
76
return false;
77
}
78
}
79
80
public static Task<T?> ReadAsync<T>(string key, CancellationToken cancellationToken = default) =>
81
ToolDataManager.ReadJsonAsync<T>(CurrentToolId, key, cancellationToken);
82
83
public static void Write<T>(string key, T value) =>
84
ToolDataManager.WriteJson(CurrentToolId, key, value);
85
86
public static Task WriteAsync<T>(string key, T value, CancellationToken cancellationToken = default) =>
87
ToolDataManager.WriteJsonAsync(CurrentToolId, key, value, cancellationToken);
88
89
public static bool Delete(string key) => ToolDataManager.DeleteEntry(CurrentToolId, key);
90
91
/// <summary>
92
/// 获取当前工具隔离目录下的文件路径。传入值必须是相对路径且不能越过工具目录。
93
/// 适用于不便以 JSON 表示的缓存或二进制数据。
94
/// </summary>
95
public static string GetFilePath(string relativePath, bool createParentDirectory = false) =>
96
ToolDataManager.GetToolFilePath(CurrentToolId, relativePath, createParentDirectory);
97
98
public static ToolWindowPlacement? ReadWindowPlacement() =>
99
Read<ToolWindowPlacement?>(WindowPlacementKey);
100
101
public static void WriteWindowPlacement(ToolWindowPlacement placement) =>
102
Write(WindowPlacementKey, placement);
103
}
104
105
/// <summary>
106
/// XFEToolBox 主程序使用的数据管理入口。它按工具 ID 查询、统计或清空数据,
107
/// 不会改变工具进程的当前数据上下文。
108
/// </summary>
109
public static class ToolDataManager
110
{
111
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
112
{
113
WriteIndented = true
114
};
115
private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks =
116
new(StringComparer.OrdinalIgnoreCase);
117
118
public static string RootDirectory => Path.Combine(
119
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
120
"XFEToolBox",
121
"CrossVersion",
122
"ToolData");
123
124
public static string ValidateToolId(string toolId)
125
{
126
ArgumentException.ThrowIfNullOrWhiteSpace(toolId);
127
var value = toolId.Trim();
128
if (value.Length > 160 || value is "." or ".." ||
129
value.Any(character => !(char.IsLetterOrDigit(character) || character is '.' or '-' or '_')))
130
throw new ArgumentException("工具 ID 只能包含字母、数字、点、横线和下划线,且长度不能超过 160。", nameof(toolId));
131
return value;
132
}
133
134
public static string GetToolDataDirectory(string toolId)
135
{
136
var validatedId = ValidateToolId(toolId);
137
var root = Path.GetFullPath(RootDirectory);
138
var target = Path.GetFullPath(Path.Combine(root, validatedId));
139
EnsureContained(root, target);
140
return target;
141
}
142
143
public static string GetToolFilePath(string toolId, string relativePath, bool createParentDirectory = false)
144
{
145
ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
146
if (Path.IsPathRooted(relativePath))
147
throw new ArgumentException("工具数据文件必须使用相对路径。", nameof(relativePath));
148
149
var toolDirectory = GetToolDataDirectory(toolId);
150
var target = Path.GetFullPath(Path.Combine(toolDirectory, relativePath));
151
EnsureContained(toolDirectory, target);
152
if (string.Equals(target, toolDirectory, StringComparison.OrdinalIgnoreCase))
153
throw new ArgumentException("工具数据文件路径不能指向数据目录本身。", nameof(relativePath));
154
155
if (createParentDirectory)
156
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
157
return target;
158
}
159
160
public static bool HasToolData(string toolId)
161
{
162
var directory = GetToolDataDirectory(toolId);
163
return Directory.Exists(directory) && Directory.EnumerateFileSystemEntries(directory).Any();
164
}
165
166
public static long GetToolDataSize(string toolId)
167
{
168
var directory = GetToolDataDirectory(toolId);
169
if (!Directory.Exists(directory)) return 0;
170
try
171
{
172
return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories)
173
.Sum(path => new FileInfo(path).Length);
174
}
175
catch (IOException)
176
{
177
return 0;
178
}
179
catch (UnauthorizedAccessException)
180
{
181
return 0;
182
}
183
}
184
185
public static bool ClearToolData(string toolId)
186
{
187
var directory = GetToolDataDirectory(toolId);
188
if (!Directory.Exists(directory)) return false;
189
Directory.Delete(directory, recursive: true);
190
return true;
191
}
192
193
public static T? ReadJson<T>(string toolId, string key) =>
194
ReadJsonAsync<T>(toolId, key).GetAwaiter().GetResult();
195
196
public static async Task<T?> ReadJsonAsync<T>(
197
string toolId,
198
string key,
199
CancellationToken cancellationToken = default)
200
{
201
var path = GetJsonPath(toolId, key);
202
if (!File.Exists(path)) return default;
203
var gate = FileLocks.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1));
204
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
205
try
206
{
207
await using var stream = new FileStream(
208
path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920,
209
FileOptions.Asynchronous | FileOptions.SequentialScan);
210
return await JsonSerializer.DeserializeAsync<T>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
211
}
212
finally
213
{
214
gate.Release();
215
}
216
}
217
218
public static void WriteJson<T>(string toolId, string key, T value) =>
219
WriteJsonAsync(toolId, key, value).GetAwaiter().GetResult();
220
221
public static async Task WriteJsonAsync<T>(
222
string toolId,
223
string key,
224
T value,
225
CancellationToken cancellationToken = default)
226
{
227
var path = GetJsonPath(toolId, key);
228
var gate = FileLocks.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1));
229
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
230
string? temporaryPath = null;
231
try
232
{
233
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
234
temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp";
235
await using (var stream = new FileStream(
236
temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920,
237
FileOptions.Asynchronous | FileOptions.SequentialScan))
238
{
239
await JsonSerializer.SerializeAsync(stream, value, JsonOptions, cancellationToken).ConfigureAwait(false);
240
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
241
}
242
File.Move(temporaryPath, path, overwrite: true);
243
temporaryPath = null;
244
}
245
finally
246
{
247
if (temporaryPath is not null && File.Exists(temporaryPath))
248
File.Delete(temporaryPath);
249
gate.Release();
250
}
251
}
252
253
public static bool DeleteEntry(string toolId, string key)
254
{
255
var path = GetJsonPath(toolId, key);
256
var gate = FileLocks.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1));
257
gate.Wait();
258
try
259
{
260
if (!File.Exists(path)) return false;
261
File.Delete(path);
262
RemoveEmptyParents(Path.GetDirectoryName(path)!, GetToolDataDirectory(toolId));
263
return true;
264
}
265
finally
266
{
267
gate.Release();
268
}
269
}
270
271
private static string GetJsonPath(string toolId, string key)
272
{
273
ArgumentException.ThrowIfNullOrWhiteSpace(key);
274
var relativePath = key.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? key : key + ".json";
275
return GetToolFilePath(toolId, relativePath);
276
}
277
278
private static void EnsureContained(string rootPath, string targetPath)
279
{
280
var root = Path.GetFullPath(rootPath)
281
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
282
var target = Path.GetFullPath(targetPath);
283
if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
284
throw new InvalidOperationException("工具数据路径越过了 XFEToolBox 管理的数据目录。 ");
285
}
286
287
private static void RemoveEmptyParents(string directory, string stopDirectory)
288
{
289
var stop = Path.GetFullPath(stopDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
290
var current = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
291
while (!string.Equals(current, stop, StringComparison.OrdinalIgnoreCase) &&
292
current.StartsWith(stop + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) &&
293
Directory.Exists(current) && !Directory.EnumerateFileSystemEntries(current).Any())
294
{
295
Directory.Delete(current);
296
current = Path.GetDirectoryName(current) ?? stop;
297
}
298
}
299
}
@@ -13,6 +13,12 @@ public sealed class ToolPackageManifest
13
13
14
14
public required string Name { get; init; }
15
15
16
/// <summary>
17
/// Optional short subtitle shown below the tool name in the standalone window title bar.
18
/// When omitted, the host falls back to the package description.
19
/// </summary>
20
public string? Subtitle { get; init; }
21
16
22
public required string Version { get; init; }
17
23
18
24
public required string Description { get; init; }
@@ -49,10 +55,10 @@ public sealed class ToolPackageManifest
49
55
50
56
public sealed class ToolWindowManifest
51
57
{
52
public const double DefaultWidth = 980;
53
public const double DefaultHeight = 700;
54
public const double DefaultMinWidth = 560;
55
public const double DefaultMinHeight = 420;
58
public const double DefaultWidth = 760;
59
public const double DefaultHeight = 560;
60
public const double DefaultMinWidth = 420;
61
public const double DefaultMinHeight = 300;
56
62
57
63
public double Width { get; init; } = DefaultWidth;
58
64
@@ -8,5 +8,17 @@ public partial class AppCacheProfile : XFEProfile
8
8
[ProfileProperty]
9
9
private string noticeText = "";
10
10
11
/// <summary>
12
/// 工具箱完整目录的 JSON 快照。页面会先读取该快照,再在后台刷新服务器数据。
13
/// </summary>
14
[ProfileProperty]
15
private string toolCatalogJson = "";
16
17
/// <summary>
18
/// 下载专区完整目录(包含分类)的 JSON 快照。
19
/// </summary>
20
[ProfileProperty]
21
private string softwareCatalogJson = "";
22
11
23
public AppCacheProfile() => ProfilePath = @$"{AppPath.CacheProfile}\{typeof(AppCacheProfile)}.xprofile";
12
}
24
}
二进制文件已变更,无法进行逐行预览。
@@ -212,14 +212,23 @@
212
212
<Style TargetType="RadioButton" BasedOn="{StaticResource ToolBoxRadioButtonStyle}"/>
213
213
214
214
<Style x:Key="ToolBoxComboBoxItemStyle" TargetType="ComboBoxItem">
215
<Setter Property="Padding" Value="11,7"/><Setter Property="Foreground" Value="{DynamicResource ToolTextPrimaryBrush}"/>
215
<Setter Property="Padding" Value="9,7"/><Setter Property="Margin" Value="0,1"/>
216
<Setter Property="Foreground" Value="{DynamicResource ToolTextPrimaryBrush}"/>
216
217
<Setter Property="Background" Value="Transparent"/><Setter Property="HorizontalContentAlignment" Value="Stretch"/>
217
218
<Setter Property="FocusVisualStyle" Value="{x:Null}"/><Setter Property="Cursor" Value="Hand"/>
218
219
<Setter Property="Template"><Setter.Value><ControlTemplate TargetType="ComboBoxItem">
219
<Border x:Name="Surface" Padding="{TemplateBinding Padding}" Background="{TemplateBinding Background}" CornerRadius="8"><ContentPresenter/></Border>
220
<Border x:Name="Surface" Padding="{TemplateBinding Padding}" Background="{TemplateBinding Background}"
221
BorderBrush="Transparent" BorderThickness="1" CornerRadius="8">
222
<Grid>
223
<Grid.ColumnDefinitions><ColumnDefinition Width="3"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions>
224
<Border x:Name="SelectedMark" Width="3" Height="14" Background="Transparent" CornerRadius="2" VerticalAlignment="Center"/>
225
<ContentPresenter Grid.Column="1" Margin="8,0,2,0" VerticalAlignment="Center"
226
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"/>
227
</Grid>
228
</Border>
220
229
<ControlTemplate.Triggers>
221
<Trigger Property="IsHighlighted" Value="True"><Setter TargetName="Surface" Property="Background" Value="{DynamicResource ToolControlHoverBrush}"/></Trigger>
222
<Trigger Property="IsSelected" Value="True"><Setter TargetName="Surface" Property="Background" Value="{DynamicResource ToolAccentSelectedBrush}"/><Setter Property="Foreground" Value="{DynamicResource MainColor}"/></Trigger>
230
<Trigger Property="IsHighlighted" Value="True"><Setter TargetName="Surface" Property="Background" Value="{DynamicResource ToolControlHoverBrush}"/><Setter TargetName="Surface" Property="BorderBrush" Value="{DynamicResource ToolDividerBrush}"/></Trigger>
231
<Trigger Property="IsSelected" Value="True"><Setter TargetName="Surface" Property="Background" Value="{DynamicResource ToolAccentSelectedBrush}"/><Setter TargetName="Surface" Property="BorderBrush" Value="{DynamicResource ToolControlHoverBorderBrush}"/><Setter TargetName="SelectedMark" Property="Background" Value="{DynamicResource MainColor}"/><Setter Property="Foreground" Value="{DynamicResource MainColor}"/><Setter Property="FontWeight" Value="SemiBold"/></Trigger>
223
232
<Trigger Property="IsEnabled" Value="False"><Setter Property="Opacity" Value="0.5"/></Trigger>
224
233
</ControlTemplate.Triggers>
225
234
</ControlTemplate></Setter.Value></Setter>
@@ -383,6 +392,37 @@
383
392
<Style TargetType="ListViewItem" BasedOn="{StaticResource ToolBoxListViewItemStyle}"/>
384
393
<Style TargetType="ListView" BasedOn="{StaticResource ToolBoxListViewStyle}"/>
385
394
395
<!-- DataGrid 单元格中的控件变体。内置列与模板列都复用这些样式。 -->
396
<Style x:Key="ToolBoxDataGridButtonStyle" TargetType="Button" BasedOn="{StaticResource ToolBoxButtonStyle}">
397
<Setter Property="Height" Value="32"/><Setter Property="MinWidth" Value="58"/><Setter Property="Padding" Value="12,0"/>
398
<Setter Property="FontSize" Value="10.5"/><Setter Property="controls:ButtonAssist.CornerRadius" Value="9"/>
399
</Style>
400
<Style x:Key="ToolBoxDataGridToggleButtonStyle" TargetType="ToggleButton" BasedOn="{StaticResource ToolBoxToggleButtonStyle}">
401
<Setter Property="Height" Value="32"/><Setter Property="MinWidth" Value="58"/><Setter Property="Padding" Value="12,0"/>
402
</Style>
403
<Style x:Key="ToolBoxDataGridTextBoxStyle" TargetType="TextBox" BasedOn="{StaticResource ToolBoxTextBoxStyle}">
404
<Setter Property="MinHeight" Value="32"/><Setter Property="Padding" Value="9,5"/><Setter Property="Margin" Value="6,4"/>
405
<Setter Property="FontSize" Value="10.5"/>
406
</Style>
407
<Style x:Key="ToolBoxDataGridEditingComboBoxStyle" TargetType="ComboBox" BasedOn="{StaticResource ToolBoxComboBoxStyle}">
408
<Setter Property="MinHeight" Value="32"/><Setter Property="Padding" Value="9,0,6,0"/><Setter Property="Margin" Value="6,4"/>
409
<Setter Property="FontSize" Value="10.5"/><Setter Property="IsSynchronizedWithCurrentItem" Value="False"/>
410
</Style>
411
<Style x:Key="ToolBoxDataGridComboBoxStyle" TargetType="ComboBox" BasedOn="{StaticResource ToolBoxDataGridEditingComboBoxStyle}">
412
<Setter Property="IsHitTestVisible" Value="False"/><Setter Property="Focusable" Value="False"/>
413
</Style>
414
<Style x:Key="ToolBoxDataGridCheckBoxStyle" TargetType="CheckBox" BasedOn="{StaticResource ToolBoxGridCheckBoxStyle}">
415
<Setter Property="IsHitTestVisible" Value="False"/><Setter Property="Focusable" Value="False"/>
416
</Style>
417
<Style x:Key="ToolBoxDataGridEditingCheckBoxStyle" TargetType="CheckBox" BasedOn="{StaticResource ToolBoxGridCheckBoxStyle}"/>
418
<Style x:Key="ToolBoxDataGridRadioButtonStyle" TargetType="RadioButton" BasedOn="{StaticResource ToolBoxRadioButtonStyle}">
419
<Setter Property="HorizontalAlignment" Value="Center"/><Setter Property="VerticalAlignment" Value="Center"/>
420
</Style>
421
<Style x:Key="ToolBoxDataGridTextElementStyle" TargetType="TextBlock">
422
<Setter Property="Foreground" Value="{DynamicResource ToolTextPrimaryBrush}"/><Setter Property="FontSize" Value="10.5"/>
423
<Setter Property="VerticalAlignment" Value="Center"/><Setter Property="TextTrimming" Value="CharacterEllipsis"/>
424
</Style>
425
386
426
<!-- 数据表格。管理页仅保留语义别名,不再复制模板。 -->
387
427
<Style x:Key="ToolBoxDataGridColumnHeaderStyle" TargetType="DataGridColumnHeader">
388
428
<Setter Property="Height" Value="44"/><Setter Property="Padding" Value="16,0"/>
@@ -439,6 +479,15 @@
439
479
<Border x:Name="CellSurface" Padding="{TemplateBinding Padding}" Background="{TemplateBinding Background}"
440
480
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"
441
481
SnapsToDevicePixels="True">
482
<Border.Resources>
483
<!-- 模板列直接放入的控件会就近取得这些隐式样式。 -->
484
<Style TargetType="Button" BasedOn="{StaticResource ToolBoxDataGridButtonStyle}"/>
485
<Style TargetType="ToggleButton" BasedOn="{StaticResource ToolBoxDataGridToggleButtonStyle}"/>
486
<Style TargetType="TextBox" BasedOn="{StaticResource ToolBoxDataGridTextBoxStyle}"/>
487
<Style TargetType="ComboBox" BasedOn="{StaticResource ToolBoxDataGridEditingComboBoxStyle}"/>
488
<Style TargetType="CheckBox" BasedOn="{StaticResource ToolBoxDataGridEditingCheckBoxStyle}"/>
489
<Style TargetType="RadioButton" BasedOn="{StaticResource ToolBoxDataGridRadioButtonStyle}"/>
490
</Border.Resources>
442
491
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
443
492
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" SnapsToDevicePixels="True"/>
444
493
</Border>
@@ -479,6 +528,13 @@
479
528
<Setter Property="ColumnHeaderStyle" Value="{StaticResource ToolBoxDataGridColumnHeaderStyle}"/>
480
529
<Setter Property="CellStyle" Value="{StaticResource ToolBoxDataGridCellStyle}"/>
481
530
<Setter Property="RowStyle" Value="{StaticResource ToolBoxDataGridRowStyle}"/>
531
<Setter Property="controls:DataGridAssist.ComboBoxElementStyle" Value="{StaticResource ToolBoxDataGridComboBoxStyle}"/>
532
<Setter Property="controls:DataGridAssist.ComboBoxEditingStyle" Value="{StaticResource ToolBoxDataGridEditingComboBoxStyle}"/>
533
<Setter Property="controls:DataGridAssist.CheckBoxElementStyle" Value="{StaticResource ToolBoxDataGridCheckBoxStyle}"/>
534
<Setter Property="controls:DataGridAssist.CheckBoxEditingStyle" Value="{StaticResource ToolBoxDataGridEditingCheckBoxStyle}"/>
535
<Setter Property="controls:DataGridAssist.TextElementStyle" Value="{StaticResource ToolBoxDataGridTextElementStyle}"/>
536
<Setter Property="controls:DataGridAssist.TextEditingStyle" Value="{StaticResource ToolBoxDataGridTextBoxStyle}"/>
537
<Setter Property="controls:DataGridAssist.UseUnifiedCellControls" Value="True"/>
482
538
<Setter Property="RowHeaderWidth" Value="0"/><Setter Property="HorizontalGridLinesBrush" Value="Transparent"/>
483
539
<Setter Property="VerticalGridLinesBrush" Value="Transparent"/><Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
484
540
<Setter Property="ScrollViewer.VerticalScrollBarVisibility" Value="Auto"/>
@@ -7,13 +7,47 @@ public sealed record BilibiliVideoInfo(string Bvid, string PictureUrl, string Ti
7
7
8
8
public static class BilibiliHelper
9
9
{
10
public const string CreatorMid = "200494622";
11
public const string CSharpSeasonId = "3641758";
12
10
13
private static readonly HttpClient HttpClient = CreateHttpClient();
11
14
12
public static async Task<IReadOnlyList<BilibiliVideoInfo>> GetSeasonVideoList(
13
string seasonId = "3641758",
15
public static Task<IReadOnlyList<BilibiliVideoInfo>> GetPopularVideoListAsync(
16
int pageSize = 6,
17
CancellationToken cancellationToken = default)
18
{
19
pageSize = Math.Clamp(pageSize, 1, 50);
20
var requestUri = $"https://api.bilibili.com/x/web-interface/popular?pn=1&ps={pageSize}";
21
return GetVideoListAsync(requestUri, static root => root["data"]?["list"], cancellationToken);
22
}
23
24
public static Task<IReadOnlyList<BilibiliVideoInfo>> GetLatestCreatorVideoListAsync(
25
int pageSize = 6,
26
CancellationToken cancellationToken = default)
27
{
28
pageSize = Math.Clamp(pageSize, 1, 50);
29
var requestUri = $"https://api.bilibili.com/x/space/arc/list?mid={CreatorMid}&pn=1&ps={pageSize}&order=pubdate";
30
return GetVideoListAsync(requestUri, static root => root["data"]?["archives"], cancellationToken);
31
}
32
33
public static Task<IReadOnlyList<BilibiliVideoInfo>> GetSeasonVideoListAsync(
34
string seasonId = CSharpSeasonId,
35
int pageSize = 10,
14
36
CancellationToken cancellationToken = default)
15
37
{
16
var requestUri = $"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?mid=200494622&season_id={Uri.EscapeDataString(seasonId)}&sort_reverse=false&page_size=30&page_num=1&web_location=333.1387";
38
pageSize = Math.Clamp(pageSize, 1, 30);
39
var requestUri = $"https://api.bilibili.com/x/polymer/web-space/seasons_archives_list?mid={CreatorMid}&season_id={Uri.EscapeDataString(seasonId)}&sort_reverse=false&page_size={pageSize}&page_num=1&web_location=333.1387";
40
return GetVideoListAsync(requestUri, static root => root["data"]?["archives"], cancellationToken);
41
}
42
43
public static Task<byte[]> GetImageBytesAsync(string imageUrl, CancellationToken cancellationToken = default) =>
44
HttpClient.GetByteArrayAsync(imageUrl, cancellationToken);
45
46
private static async Task<IReadOnlyList<BilibiliVideoInfo>> GetVideoListAsync(
47
string requestUri,
48
Func<XFEJsonNode, XFEJsonNode?> selectItems,
49
CancellationToken cancellationToken)
50
{
17
51
var responseContent = await HttpClient.GetStringAsync(requestUri, cancellationToken);
18
52
XFEJsonNode root = responseContent;
19
53
@@ -23,16 +57,16 @@ public static class BilibiliHelper
23
57
throw new HttpRequestException($"Bilibili 接口返回异常:{message}");
24
58
}
25
59
26
var archives = root["data"]?["archives"];
27
if (archives is null)
60
var items = selectItems(root);
61
if (items is null)
28
62
return [];
29
63
30
64
var videos = new List<BilibiliVideoInfo>();
31
foreach (var archive in archives.EnumerateArray())
65
foreach (var item in items.EnumerateArray())
32
66
{
33
var bvid = archive["bvid"]?.GetString()?.Trim();
34
var pictureUrl = archive["pic"]?.GetString()?.Trim();
35
var title = archive["title"]?.GetString()?.Trim();
67
var bvid = item["bvid"]?.GetString()?.Trim();
68
var pictureUrl = item["pic"]?.GetString()?.Trim();
69
var title = item["title"]?.GetString()?.Trim();
36
70
if (string.IsNullOrWhiteSpace(bvid) ||
37
71
string.IsNullOrWhiteSpace(pictureUrl) ||
38
72
string.IsNullOrWhiteSpace(title))
@@ -44,9 +78,6 @@ public static class BilibiliHelper
44
78
return videos;
45
79
}
46
80
47
public static Task<byte[]> GetImageBytesAsync(string imageUrl, CancellationToken cancellationToken = default) =>
48
HttpClient.GetByteArrayAsync(imageUrl, cancellationToken);
49
50
81
private static string NormalizePictureUrl(string pictureUrl)
51
82
{
52
83
if (pictureUrl.StartsWith("//", StringComparison.Ordinal))
@@ -63,7 +94,7 @@ public static class BilibiliHelper
63
94
Timeout = TimeSpan.FromSeconds(12)
64
95
};
65
96
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/145.0.0.0 Safari/537.36");
66
client.DefaultRequestHeaders.Referrer = new Uri("https://space.bilibili.com/200494622/");
97
client.DefaultRequestHeaders.Referrer = new Uri($"https://space.bilibili.com/{CreatorMid}/");
67
98
client.DefaultRequestHeaders.Accept.ParseAdd("application/json,image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
68
99
return client;
69
100
}
@@ -224,8 +224,14 @@ internal static class ToolProjectRunService
224
224
string? toolIconPath)
225
225
{
226
226
var window = NormalizeWindowSettings(manifest.Window);
227
var toolId = JsonSerializer.Serialize(manifest.Id.Trim());
227
228
var viewClass = JsonSerializer.Serialize(manifest.Entry.ViewClass);
228
229
var title = JsonSerializer.Serialize(windowTitle);
230
var displayTitle = JsonSerializer.Serialize(manifest.Name.Trim());
231
var subtitle = JsonSerializer.Serialize(
232
string.IsNullOrWhiteSpace(manifest.Subtitle)
233
? manifest.Description.Trim()
234
: manifest.Subtitle.Trim());
229
235
var iconPath = JsonSerializer.Serialize(toolIconPath);
230
236
var themeResourceUri = JsonSerializer.Serialize(
231
237
$"pack://application:,,,/{hostAssemblyName};component/Resources/Style/ToolThemeResources.xaml");
@@ -248,7 +254,9 @@ internal static class ToolProjectRunService
248
254
using System.Windows.Controls;
249
255
using System.Windows.Media;
250
256
using System.Windows.Media.Imaging;
257
using System.Windows.Threading;
251
258
using XFEToolBox.Client.Views.Controls;
259
using XFEToolBox.Core.Tools;
252
260
253
261
namespace XFEToolBox.RuntimeHost;
254
262
@@ -268,6 +276,7 @@ internal static class ToolProjectRunService
268
276
});
269
277
try
270
278
{
279
ToolDataStore.Initialize({{toolId}});
271
280
var viewType = Assembly.GetExecutingAssembly().GetType({{viewClass}}, throwOnError: true)!;
272
281
var instance = Activator.CreateInstance(viewType)
273
282
?? throw new InvalidOperationException("无法创建入口视图实例。");
@@ -298,23 +307,36 @@ internal static class ToolProjectRunService
298
307
299
308
private static void ConfigureWindow(Application application, Window window, object content)
300
309
{
310
var savedPlacement = ToolDataStore.ReadWindowPlacement();
301
311
window.Title = {{title}};
302
window.Width = {{width}};
303
window.Height = {{height}};
304
312
window.MinWidth = {{minWidth}};
305
313
window.MinHeight = {{minHeight}};
314
window.Width = NormalizePlacementDimension(savedPlacement?.Width, {{width}}, window.MinWidth);
315
window.Height = NormalizePlacementDimension(savedPlacement?.Height, {{height}}, window.MinHeight);
306
316
window.SizeToContent = SizeToContent.Manual;
307
317
window.WindowState = WindowState.Normal;
308
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
318
if (savedPlacement is not null && IsPlacementVisible(savedPlacement))
319
{
320
window.WindowStartupLocation = WindowStartupLocation.Manual;
321
window.Left = savedPlacement.Left;
322
window.Top = savedPlacement.Top;
323
}
324
else
325
{
326
window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
327
}
309
328
window.WindowStyle = WindowStyle.None;
310
329
window.AllowsTransparency = true;
311
330
window.ResizeMode = {{allowResize}} ? ResizeMode.CanResize : ResizeMode.NoResize;
312
331
window.Background = Brushes.Transparent;
313
332
window.Foreground = (Brush)application.FindResource("ToolTextPrimaryBrush");
314
window.Icon = LoadWindowIcon();
333
var windowIcon = LoadWindowIcon();
334
window.Icon = windowIcon;
315
335
316
336
var captionBar = new WindowCaptionBar
317
337
{
338
Height = 25,
339
VerticalAlignment = VerticalAlignment.Top,
318
340
AllowMaximize = {{allowResize}} && {{allowMaximize}},
319
341
DragHandleVisibility = Visibility.Visible,
320
342
MinimizeButtonVisibility = {{showMinimizeButton}} ? Visibility.Visible : Visibility.Collapsed,
@@ -322,6 +344,63 @@ internal static class ToolProjectRunService
322
344
};
323
345
Grid.SetRow(captionBar, 0);
324
346
347
var titleIcon = new Image
348
{
349
Source = windowIcon,
350
Width = 28,
351
Height = 28,
352
Stretch = Stretch.Uniform,
353
HorizontalAlignment = HorizontalAlignment.Center,
354
VerticalAlignment = VerticalAlignment.Center
355
};
356
var titleIconSurface = new Border
357
{
358
Width = 42,
359
Height = 42,
360
CornerRadius = new CornerRadius(12),
361
Background = Brushes.White,
362
Child = titleIcon
363
};
364
var titleText = new TextBlock
365
{
366
Text = {{displayTitle}},
367
Foreground = Brushes.White,
368
FontSize = 16,
369
FontWeight = FontWeights.SemiBold,
370
TextTrimming = TextTrimming.CharacterEllipsis
371
};
372
var subtitleText = new TextBlock
373
{
374
Text = {{subtitle}},
375
Foreground = Brushes.White,
376
FontSize = 10.5,
377
Opacity = 0.82,
378
Margin = new Thickness(0, 2, 0, 0),
379
TextTrimming = TextTrimming.CharacterEllipsis
380
};
381
var titleTextPanel = new StackPanel
382
{
383
Margin = new Thickness(11, 0, 0, 0),
384
VerticalAlignment = VerticalAlignment.Center
385
};
386
titleTextPanel.Children.Add(titleText);
387
titleTextPanel.Children.Add(subtitleText);
388
389
var titleIdentity = new Grid
390
{
391
Margin = new Thickness(18, 9, 100, 9),
392
HorizontalAlignment = HorizontalAlignment.Stretch,
393
VerticalAlignment = VerticalAlignment.Center,
394
IsHitTestVisible = false
395
};
396
titleIdentity.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
397
titleIdentity.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
398
Grid.SetColumn(titleIconSurface, 0);
399
Grid.SetColumn(titleTextPanel, 1);
400
titleIdentity.Children.Add(titleIconSurface);
401
titleIdentity.Children.Add(titleTextPanel);
402
Grid.SetRow(titleIdentity, 0);
403
325
404
var contentPresenter = new ContentControl
326
405
{
327
406
Content = content,
@@ -331,15 +410,16 @@ internal static class ToolProjectRunService
331
410
var contentSurface = new Border
332
411
{
333
412
Background = (Brush)application.FindResource("ToolSurfaceBrush"),
334
CornerRadius = new CornerRadius(0, 0, 17, 17),
413
CornerRadius = new CornerRadius(16, 16, 17, 17),
335
414
Child = contentPresenter
336
415
};
337
416
Grid.SetRow(contentSurface, 1);
338
417
339
418
var layout = new Grid();
340
layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(25) });
419
layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(64) });
341
420
layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
342
421
layout.Children.Add(captionBar);
422
layout.Children.Add(titleIdentity);
343
423
layout.Children.Add(contentSurface);
344
424
345
425
var windowSurface = new RoundedClipBorder
@@ -359,6 +439,94 @@ internal static class ToolProjectRunService
359
439
});
360
440
}
361
441
window.Content = windowRoot;
442
AttachWindowPlacementPersistence(window, savedPlacement);
443
}
444
445
private static double NormalizePlacementDimension(double? value, double fallback, double minimum)
446
{
447
if (value is not { } candidate || !double.IsFinite(candidate) || candidate <= 0)
448
return fallback;
449
return Math.Max(minimum, candidate);
450
}
451
452
private static bool IsPlacementVisible(ToolWindowPlacement placement)
453
{
454
if (!double.IsFinite(placement.Left) || !double.IsFinite(placement.Top) ||
455
!double.IsFinite(placement.Width) || !double.IsFinite(placement.Height) ||
456
placement.Width <= 0 || placement.Height <= 0)
457
return false;
458
459
const double visibleEdge = 72;
460
var virtualLeft = SystemParameters.VirtualScreenLeft;
461
var virtualTop = SystemParameters.VirtualScreenTop;
462
var virtualRight = virtualLeft + SystemParameters.VirtualScreenWidth;
463
var virtualBottom = virtualTop + SystemParameters.VirtualScreenHeight;
464
return placement.Left + placement.Width >= virtualLeft + visibleEdge &&
465
placement.Top + visibleEdge <= virtualBottom &&
466
placement.Left + visibleEdge <= virtualRight &&
467
placement.Top + placement.Height >= virtualTop + visibleEdge;
468
}
469
470
private static void AttachWindowPlacementPersistence(Window window, ToolWindowPlacement? savedPlacement)
471
{
472
var lastVisibleState = string.Equals(
473
savedPlacement?.LastVisibleState,
474
nameof(WindowState.Maximized),
475
StringComparison.Ordinal)
476
? WindowState.Maximized
477
: WindowState.Normal;
478
var saveTimer = new DispatcherTimer(DispatcherPriority.Background)
479
{
480
Interval = TimeSpan.FromMilliseconds(350)
481
};
482
483
void SavePlacement()
484
{
485
saveTimer.Stop();
486
var state = window.WindowState;
487
if (state != WindowState.Minimized)
488
lastVisibleState = state == WindowState.Maximized ? WindowState.Maximized : WindowState.Normal;
489
490
var bounds = state == WindowState.Normal
491
? new Rect(window.Left, window.Top, window.ActualWidth, window.ActualHeight)
492
: window.RestoreBounds;
493
if (!double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Top) ||
494
!double.IsFinite(bounds.Width) || !double.IsFinite(bounds.Height) ||
495
bounds.Width <= 0 || bounds.Height <= 0)
496
return;
497
498
ToolDataStore.WriteWindowPlacement(new ToolWindowPlacement(
499
bounds.Left,
500
bounds.Top,
501
bounds.Width,
502
bounds.Height,
503
state.ToString(),
504
lastVisibleState.ToString(),
505
state == WindowState.Minimized,
506
DateTimeOffset.UtcNow));
507
}
508
509
void QueueSave()
510
{
511
saveTimer.Stop();
512
saveTimer.Start();
513
}
514
515
saveTimer.Tick += (_, _) => SavePlacement();
516
window.SizeChanged += (_, _) => QueueSave();
517
window.LocationChanged += (_, _) => QueueSave();
518
window.StateChanged += (_, _) => QueueSave();
519
window.Closing += (_, _) => SavePlacement();
520
window.Loaded += (_, _) =>
521
{
522
// 记录最小化状态,但启动时恢复到最后一个可见状态,避免用户误以为工具没有打开。
523
var restoreMaximized = {{allowResize}} && {{allowMaximize}} &&
524
(string.Equals(savedPlacement?.State, nameof(WindowState.Maximized), StringComparison.Ordinal) ||
525
string.Equals(savedPlacement?.State, nameof(WindowState.Minimized), StringComparison.Ordinal) &&
526
string.Equals(savedPlacement?.LastVisibleState, nameof(WindowState.Maximized), StringComparison.Ordinal));
527
if (restoreMaximized)
528
window.WindowState = WindowState.Maximized;
529
};
362
530
}
363
531
364
532
private static ImageSource LoadWindowIcon()
@@ -392,9 +560,9 @@ internal static class ToolProjectRunService
392
560
{
393
561
settings ??= new ToolWindowManifest();
394
562
var minWidth = NormalizeDimension(settings.MinWidth, ToolWindowManifest.DefaultMinWidth, 320, 3840);
395
var minHeight = NormalizeDimension(settings.MinHeight, ToolWindowManifest.DefaultMinHeight, 240, 2160);
563
var minHeight = NormalizeDimension(settings.MinHeight, ToolWindowManifest.DefaultMinHeight, 220, 2160);
396
564
var width = Math.Max(minWidth, NormalizeDimension(settings.Width, ToolWindowManifest.DefaultWidth, 320, 3840));
397
var height = Math.Max(minHeight, NormalizeDimension(settings.Height, ToolWindowManifest.DefaultHeight, 240, 2160));
565
var height = Math.Max(minHeight, NormalizeDimension(settings.Height, ToolWindowManifest.DefaultHeight, 220, 2160));
398
566
return new NormalizedWindowSettings(
399
567
width,
400
568
height,
@@ -60,6 +60,7 @@ internal static class ToolProjectWorkspaceService
60
60
"packageFormatVersion": 1,
61
61
"id": "local.{{safeId}}",
62
62
"name": {{jsonProjectName}},
63
"subtitle": "WPF 独立工具",
63
64
"version": "1.0.0",
64
65
"description": "请在这里填写工具说明。",
65
66
"author": "XFEstudio",
@@ -74,10 +75,10 @@ internal static class ToolProjectWorkspaceService
74
75
"viewModelClass": "{{toolNamespace}}.MainPageViewModel"
75
76
},
76
77
"window": {
77
"width": 980,
78
"height": 700,
79
"minWidth": 560,
80
"minHeight": 420,
78
"width": 760,
79
"height": 560,
80
"minWidth": 420,
81
"minHeight": 300,
81
82
"allowResize": true,
82
83
"allowMaximize": true,
83
84
"showMinimizeButton": true,
@@ -91,14 +92,12 @@ internal static class ToolProjectWorkspaceService
91
92
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
92
93
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
93
94
<Grid Margin="24">
94
<StackPanel>
95
<TextBlock Text="{{xamlProjectName}}" FontSize="28" FontWeight="Bold" />
96
<TextBlock Text="在这里编写工具界面" Margin="0,8,0,0" />
97
</StackPanel>
95
<TextBlock Text="在这里编写工具界面" />
98
96
</Grid>
99
97
</UserControl>
100
98
""",
101
99
[Path.Combine("Code", "Views", "MainPage.xaml.cs")] = $$"""
100
using System.Windows;
102
101
using System.Windows.Controls;
103
102
104
103
namespace {{toolNamespace}};
@@ -109,12 +108,17 @@ internal static class ToolProjectWorkspaceService
109
108
{
110
109
InitializeComponent();
111
110
DataContext = new MainPageViewModel();
111
Unloaded += OnUnloaded;
112
112
}
113
114
private void OnUnloaded(object sender, RoutedEventArgs e) =>
115
((MainPageViewModel)DataContext).SaveSettings();
113
116
}
114
117
""",
115
118
[Path.Combine("Code", "ViewModels", "MainPageViewModel.cs")] = $$"""
116
119
using CommunityToolkit.Mvvm.ComponentModel;
117
120
using CommunityToolkit.Mvvm.Input;
121
using XFEToolBox.Core.Tools;
118
122
119
123
namespace {{toolNamespace}};
120
124
@@ -123,8 +127,22 @@ internal static class ToolProjectWorkspaceService
123
127
[ObservableProperty]
124
128
private string result = string.Empty;
125
129
130
public MainPageViewModel()
131
{
132
if (ToolDataStore.IsInitialized)
133
result = ToolDataStore.Read("settings", new ToolSettings(string.Empty)).LastResult;
134
}
135
136
public void SaveSettings()
137
{
138
if (ToolDataStore.IsInitialized)
139
ToolDataStore.Write("settings", new ToolSettings(Result));
140
}
141
126
142
[RelayCommand]
127
143
private void Execute() => Result = "工具执行成功";
144
145
private sealed record ToolSettings(string LastResult);
128
146
}
129
147
""",
130
148
[Path.Combine("Code", "Models", "ToolModel.cs")] = $$"""
@@ -17,6 +17,11 @@ namespace XFEToolBox.Client.ViewModel.Pages;
17
17
public partial class MainPageViewModel : ObservableObject
18
18
{
19
19
private const int MaximumAttempts = 3;
20
private const int CoverDownloadAttempts = 2;
21
private const int PopularVideoCount = 3;
22
private const int LatestVideoCount = 2;
23
private const int TutorialVideoCount = 2;
24
private const int ExpectedCarouselItemCount = PopularVideoCount + LatestVideoCount + TutorialVideoCount;
20
25
private Task? loadingTask;
21
26
private readonly DispatcherTimer adminRefreshTimer;
22
27
private bool isAdminOverviewLoading;
@@ -126,57 +131,127 @@ public partial class MainPageViewModel : ObservableObject
126
131
var carousel = MainPage.mainCarousel;
127
132
carousel.IsLoading = true;
128
133
carousel.CanRetry = false;
129
carousel.StatusMessage = "正在获取精选内容…";
134
carousel.StatusMessage = "正在获取 B 站热门与最新视频…";
130
135
136
try
137
{
138
var groups = await Task.WhenAll(
139
LoadVideoGroupAsync(
140
"B站热门",
141
PopularVideoCount,
142
() => BilibiliHelper.GetPopularVideoListAsync(8)),
143
LoadVideoGroupAsync(
144
"我的最新",
145
LatestVideoCount,
146
() => BilibiliHelper.GetLatestCreatorVideoListAsync(8)),
147
LoadVideoGroupAsync(
148
"芝士 C#",
149
TutorialVideoCount,
150
() => BilibiliHelper.GetSeasonVideoListAsync(pageSize: 10)));
151
152
foreach (var group in groups.Where(group => group.Videos.Count < group.RequiredCount))
153
{
154
Debug.WriteLine(
155
$"轮播分组“{group.Badge}”仅获取到 {group.Videos.Count}/{group.RequiredCount} 项:{group.LastException?.Message}");
156
}
157
158
var candidates = ComposeOrderedVideos(groups);
159
if (candidates.Count == 0)
160
throw new HttpRequestException("Bilibili 内容源暂时没有返回可展示的视频");
161
162
var downloadedCovers = await Task.WhenAll(candidates.Select(DownloadCoverAsync));
163
var carouselItems = new List<CarouselImageItem>(downloadedCovers.Length);
164
165
foreach (var downloadedCover in downloadedCovers.OfType<DownloadedCover>())
166
{
167
var image = CreateBitmapImage(downloadedCover.ImageBytes);
168
var video = downloadedCover.Candidate.Video;
169
var videoUrl = $"https://www.bilibili.com/video/{video.Bvid}";
170
carouselItems.Add(new CarouselImageItem
171
{
172
Image = image,
173
Title = video.Title,
174
Badge = downloadedCover.Candidate.Badge,
175
Action = () => OpenExternalLink(videoUrl)
176
});
177
}
178
179
if (carouselItems.Count == 0)
180
throw new HttpRequestException("轮播封面图片全部下载失败");
181
182
carousel.SetItems(carouselItems);
183
carousel.StatusMessage = carouselItems.Count == ExpectedCarouselItemCount
184
? string.Empty
185
: $"已加载 {carouselItems.Count}/{ExpectedCarouselItemCount} 项内容";
186
carousel.CanRetry = carouselItems.Count < ExpectedCarouselItemCount;
187
}
188
catch (Exception exception)
189
{
190
Debug.WriteLine($"主页轮播内容加载失败:{exception}");
191
carousel.StatusMessage = "网络连接失败,请稍后重新加载";
192
carousel.CanRetry = true;
193
}
194
finally
195
{
196
carousel.IsLoading = false;
197
}
198
}
199
200
private static async Task<VideoGroupResult> LoadVideoGroupAsync(
201
string badge,
202
int requiredCount,
203
Func<Task<IReadOnlyList<BilibiliVideoInfo>>> loadAsync)
204
{
205
IReadOnlyList<BilibiliVideoInfo> bestResult = [];
131
206
Exception? lastException = null;
132
207
133
208
for (var attempt = 1; attempt <= MaximumAttempts; attempt++)
134
209
{
135
210
try
136
211
{
137
var videoInfoList = await BilibiliHelper.GetSeasonVideoList();
138
var downloadTasks = videoInfoList.Select(DownloadCoverAsync).ToArray();
139
var downloadedCovers = await Task.WhenAll(downloadTasks);
140
var carouselItems = new List<CarouselImageItem>(downloadedCovers.Length);
212
var videos = await loadAsync();
213
if (videos.Count > bestResult.Count)
214
bestResult = videos;
215
if (bestResult.Count >= requiredCount)
216
break;
141
217
142
foreach (var downloadedCover in downloadedCovers.OfType<DownloadedCover>())
143
{
144
var image = CreateBitmapImage(downloadedCover.ImageBytes);
145
var videoUrl = $"https://www.bilibili.com/video/{downloadedCover.Video.Bvid}";
146
carouselItems.Add(new CarouselImageItem
147
{
148
Image = image,
149
Title = downloadedCover.Video.Title,
150
Action = () => OpenExternalLink(videoUrl)
151
});
152
}
153
154
if (videoInfoList.Count > 0 && carouselItems.Count == 0)
155
throw new HttpRequestException("封面图片全部下载失败");
156
157
carousel.SetItems(carouselItems);
158
carousel.StatusMessage = carouselItems.Count == 0
159
? "内容源暂时没有返回可展示的项目"
160
: string.Empty;
161
carousel.CanRetry = carouselItems.Count == 0;
162
carousel.IsLoading = false;
163
return;
218
lastException = new HttpRequestException(
219
$"内容源仅返回 {bestResult.Count}/{requiredCount} 项视频");
164
220
}
165
catch (Exception ex)
221
catch (Exception exception)
166
222
{
167
lastException = ex;
168
if (attempt < MaximumAttempts)
169
{
170
carousel.StatusMessage = $"连接不稳定,正在重试({attempt}/{MaximumAttempts - 1})…";
171
await Task.Delay(TimeSpan.FromMilliseconds(1000 * attempt));
172
}
223
lastException = exception;
224
}
225
226
if (attempt < MaximumAttempts)
227
await Task.Delay(TimeSpan.FromMilliseconds(750 * attempt));
228
}
229
230
return new VideoGroupResult(badge, requiredCount, bestResult, lastException);
231
}
232
233
private static IReadOnlyList<CarouselVideoCandidate> ComposeOrderedVideos(
234
IEnumerable<VideoGroupResult> groups)
235
{
236
var result = new List<CarouselVideoCandidate>(ExpectedCarouselItemCount);
237
var usedBvids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
238
239
foreach (var group in groups)
240
{
241
var addedCount = 0;
242
foreach (var video in group.Videos)
243
{
244
if (!usedBvids.Add(video.Bvid))
245
continue;
246
247
result.Add(new CarouselVideoCandidate(video, group.Badge));
248
addedCount++;
249
if (addedCount >= group.RequiredCount)
250
break;
173
251
}
174
252
}
175
253
176
Debug.WriteLine($"主页轮播内容加载失败:{lastException}");
177
carousel.StatusMessage = "网络连接失败,请稍后重新加载";
178
carousel.CanRetry = true;
179
carousel.IsLoading = false;
254
return result;
180
255
}
181
256
182
257
private static void OpenExternalLink(string url)
@@ -188,18 +263,24 @@ public partial class MainPageViewModel : ObservableObject
188
263
});
189
264
}
190
265
191
private static async Task<DownloadedCover?> DownloadCoverAsync(BilibiliVideoInfo video)
266
private static async Task<DownloadedCover?> DownloadCoverAsync(CarouselVideoCandidate candidate)
192
267
{
193
try
194
{
195
var imageBytes = await BilibiliHelper.GetImageBytesAsync(video.PictureUrl);
196
return imageBytes.Length == 0 ? null : new DownloadedCover(video, imageBytes);
197
}
198
catch (Exception ex)
268
for (var attempt = 1; attempt <= CoverDownloadAttempts; attempt++)
199
269
{
200
Debug.WriteLine($"轮播封面下载失败({video.Bvid}):{ex.Message}");
201
return null;
270
try
271
{
272
var imageBytes = await BilibiliHelper.GetImageBytesAsync(candidate.Video.PictureUrl);
273
if (imageBytes.Length > 0)
274
return new DownloadedCover(candidate, imageBytes);
275
}
276
catch (Exception exception)
277
{
278
if (attempt == CoverDownloadAttempts)
279
Debug.WriteLine($"轮播封面下载失败({candidate.Video.Bvid}):{exception.Message}");
280
}
202
281
}
282
283
return null;
203
284
}
204
285
205
286
private static BitmapImage CreateBitmapImage(byte[] imageBytes)
@@ -229,5 +310,13 @@ public partial class MainPageViewModel : ObservableObject
229
310
return value.TotalDays >= 1 ? $"{(int)value.TotalDays} 天 {value.Hours} 小时" : $"{value.Hours} 小时 {value.Minutes} 分";
230
311
}
231
312
232
private sealed record DownloadedCover(BilibiliVideoInfo Video, byte[] ImageBytes);
313
private sealed record VideoGroupResult(
314
string Badge,
315
int RequiredCount,
316
IReadOnlyList<BilibiliVideoInfo> Videos,
317
Exception? LastException);
318
319
private sealed record CarouselVideoCandidate(BilibiliVideoInfo Video, string Badge);
320
321
private sealed record DownloadedCover(CarouselVideoCandidate Candidate, byte[] ImageBytes);
233
322
}
@@ -134,9 +134,9 @@
134
134
VerticalAlignment="Bottom"
135
135
Visibility="{Binding HasItems, RelativeSource={RelativeSource AncestorType=UserControl}, Converter={StaticResource BooleanToVisibilityConverter}}">
136
136
<StackPanel VerticalAlignment="Bottom">
137
<Border Width="74" Height="23" HorizontalAlignment="Left" Margin="0,0,0,7"
137
<Border MinWidth="74" Height="23" Padding="12,0" HorizontalAlignment="Left" Margin="0,0,0,7"
138
138
Background="#389898E7" BorderBrush="#66FFFFFF" BorderThickness="1" CornerRadius="11.5">
139
<TextBlock Text="精选教程" Foreground="#F2FFFFFF" FontSize="10.5"
139
<TextBlock Text="{Binding CurrentBadge, RelativeSource={RelativeSource AncestorType=UserControl}}" Foreground="#F2FFFFFF" FontSize="10.5"
140
140
FontWeight="SemiBold" HorizontalAlignment="Center" VerticalAlignment="Center"/>
141
141
</Border>
142
142
<TextBlock Text="{Binding CurrentTitle, RelativeSource={RelativeSource AncestorType=UserControl}}"
@@ -63,6 +63,20 @@ public partial class Carousel : UserControl, INotifyPropertyChanged
63
63
64
64
public static readonly DependencyProperty CurrentTitleProperty = CurrentTitlePropertyKey.DependencyProperty;
65
65
66
public string CurrentBadge
67
{
68
get => (string)GetValue(CurrentBadgeProperty);
69
private set => SetValue(CurrentBadgePropertyKey, value);
70
}
71
72
private static readonly DependencyPropertyKey CurrentBadgePropertyKey = DependencyProperty.RegisterReadOnly(
73
nameof(CurrentBadge),
74
typeof(string),
75
typeof(Carousel),
76
new PropertyMetadata(string.Empty));
77
78
public static readonly DependencyProperty CurrentBadgeProperty = CurrentBadgePropertyKey.DependencyProperty;
79
66
80
public bool AutoPlay
67
81
{
68
82
get => (bool)GetValue(AutoPlayProperty);
@@ -270,7 +284,8 @@ public partial class Carousel : UserControl, INotifyPropertyChanged
270
284
if (!ReferenceEquals(sender, currentItem))
271
285
return;
272
286
273
if (e.PropertyName is nameof(CarouselImageItem.Image) or nameof(CarouselImageItem.Title) or nameof(CarouselImageItem.Action))
287
if (e.PropertyName is nameof(CarouselImageItem.Image) or nameof(CarouselImageItem.Title)
288
or nameof(CarouselImageItem.Badge) or nameof(CarouselImageItem.Action))
274
289
UpdateImage(skipTransition: true);
275
290
}
276
291
@@ -307,6 +322,7 @@ public partial class Carousel : UserControl, INotifyPropertyChanged
307
322
currentItem = null;
308
323
CurrentImageSource = null;
309
324
CurrentTitle = string.Empty;
325
CurrentBadge = string.Empty;
310
326
ImageFront.Source = null;
311
327
ImageBack.Source = null;
312
328
NotifyStateChanged();
@@ -322,6 +338,7 @@ public partial class Carousel : UserControl, INotifyPropertyChanged
322
338
323
339
CurrentImageSource = nextItem.Image;
324
340
CurrentTitle = nextItem.Title;
341
CurrentBadge = nextItem.Badge;
325
342
currentItem = nextItem;
326
343
327
344
if (!itemChanged || skipTransition)