XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEToolBox

实现工具数据隔离存储与目录缓存机制

本次更新引入 ToolDataStore/ToolDataManager,实现工具级别的数据隔离存储,支持 JSON 读写、窗口状态自动保存、二进制路径获取及一键清除等功能。主界面和软件下载页新增本地目录缓存,支持离线浏览和后台自动刷新。工具窗口标题栏支持副标题,manifest.json 可配置。工具卡片右键菜单支持一键清除数据。新增 DataGridAssist 统一表格控件样式,优化 ComboBoxItem、DataGrid、软件卡片等 UI 细节。首页轮播支持多分组及更健壮的内容下载。同步更新模板和文档,新增 RuntimeHostSmoke 工程提升可测试性。其他细节优化包括状态提示、控件对齐、输入框尺寸等。

1b4e00a
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

24 个文件 +1460 -142
Added .BuildValidation/RuntimeHostSmoke/Program.cs +48 -0
@@ -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);
Added .BuildValidation/RuntimeHostSmoke/RuntimeHostSmoke.csproj +14 -0
@@ -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>
Added XFEToolBox.Client.Core/Tools/ToolDataStore.cs +299 -0
@@ -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 }
Modified XFEToolBox.Core/Tools/ToolPackageManifest.cs +10 -4
@@ -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
Modified XFEToolBox/Profiles/CacheProfiles/AppCacheProfile.cs +13 -1
@@ -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 }
Modified XFEToolBox/Resources/Image/default_tool_icon.png +0 -0
二进制文件已变更,无法进行逐行预览。
Modified XFEToolBox/Resources/Style/StandardControlsStyle.xaml +60 -4
@@ -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"/>
Modified XFEToolBox/Utilities/Helpers/BilibiliHelper.cs +44 -13
@@ -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 }
Modified XFEToolBox/Utilities/ToolProjectRunService.cs +176 -8
@@ -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,
Modified XFEToolBox/Utilities/ToolProjectWorkspaceService.cs +26 -8
@@ -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")] = $$"""
Modified XFEToolBox/ViewModel/Pages/MainPageViewModel.cs +137 -48
@@ -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 }
Modified XFEToolBox/Views/Controls/Carousel.xaml +2 -2
@@ -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}}"
Modified XFEToolBox/Views/Controls/Carousel.xaml.cs +18 -1
@@ -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)
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