From 2c985e8d63cc5d90f7d053c2285394a35f8bf867 Mon Sep 17 00:00:00 2001 From: home-PC Date: Sun, 10 May 2026 00:15:39 +0800 Subject: [PATCH] =?UTF-8?q?Phase=206:=20=E4=B8=80=E9=94=AE=E5=A4=9A?= =?UTF-8?q?=E5=BC=80=20(=E5=B7=A5=E5=85=B7=E7=BB=84=E5=90=88)=20=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据模型: ToolItem 新增 IsGroup(bool) + SubToolIds(List) 字段 - 执行逻辑: ProcessExecutionService 改为 ExecuteAsync, 组合卡片遍历子工具逐一启动(500ms延迟), 孤儿ID跳过并打印警告 - 组合编辑: GroupEditViewModel + GroupEditWindow, 复选框列表勾选非组合工具 - 主界面: 标题栏新增 '+添加组合' 按钮(蓝色), 组合卡片右下角显示 📦 角标 - 右键菜单: 区分 '编辑工具' (普通) 和 '编辑组合' (IsGroup=true) - 快捷键: HotKeyManager 适配 ExecuteAsync 异步调用 - 测试: 82 tests total (ProcessExecution 4->6, GroupEdit 5 new) --- .../Services/ProcessExecutionServiceTests.cs | 78 ++++++++-- .../ViewModels/GroupEditViewModelTests.cs | 97 ++++++++++++ .../ViewModels/MainViewModelTests.cs | 4 +- PersonalToolBox/App.xaml.cs | 1 + PersonalToolBox/Helpers/HotKeyManager.cs | 2 +- PersonalToolBox/Models/ToolItem.cs | 10 ++ .../Services/IProcessExecutionService.cs | 5 +- .../Services/ProcessExecutionService.cs | 58 ++++++- .../ViewModels/GroupEditViewModel.cs | 144 ++++++++++++++++++ PersonalToolBox/ViewModels/MainViewModel.cs | 36 ++++- PersonalToolBox/Views/GroupEditWindow.xaml | 100 ++++++++++++ PersonalToolBox/Views/GroupEditWindow.xaml.cs | 17 +++ PersonalToolBox/Views/MainWindow.xaml | 74 ++++++++- 补充需求文档-01.md | 73 +++++++++ 14 files changed, 668 insertions(+), 31 deletions(-) create mode 100644 PersonalToolBox.Tests/ViewModels/GroupEditViewModelTests.cs create mode 100644 PersonalToolBox/ViewModels/GroupEditViewModel.cs create mode 100644 PersonalToolBox/Views/GroupEditWindow.xaml create mode 100644 PersonalToolBox/Views/GroupEditWindow.xaml.cs create mode 100644 补充需求文档-01.md diff --git a/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs b/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs index 58cb9f0..d9e3639 100644 --- a/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs +++ b/PersonalToolBox.Tests/Services/ProcessExecutionServiceTests.cs @@ -7,19 +7,26 @@ namespace PersonalToolBox.Tests.Services; public class ProcessExecutionServiceTests { private readonly Mock _logServiceMock = new(); + private readonly Mock _dataServiceMock = new(); + private readonly AppConfig _config = new(); + + public ProcessExecutionServiceTests() + { + _dataServiceMock.Setup(d => d.Config).Returns(_config); + } [Fact] - public void Execute_NullTool_LogsError() + public async Task Execute_NullTool_LogsError() { - var service = new ProcessExecutionService(_logServiceMock.Object); + var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object); - service.Execute(null!); + await service.ExecuteAsync(null!); _logServiceMock.Verify(x => x.Error(It.Is(s => s.Contains("空工具项"))), Times.Once); } [Fact] - public void Execute_InvalidTool_LogsWarning() + public async Task Execute_InvalidTool_LogsWarning() { var tool = new ToolItem { @@ -27,15 +34,15 @@ public class ProcessExecutionServiceTests ExecutablePath = @"C:\nonexistent.exe", IsValid = false }; - var service = new ProcessExecutionService(_logServiceMock.Object); + var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object); - service.Execute(tool); + await service.ExecuteAsync(tool); _logServiceMock.Verify(x => x.Warning(It.Is(s => s.Contains("无法运行") && s.Contains("BadTool"))), Times.Once); } [Fact] - public void Execute_ProcessFails_LogsError() + public async Task Execute_ProcessFails_LogsError() { var tool = new ToolItem { @@ -43,28 +50,71 @@ public class ProcessExecutionServiceTests ExecutablePath = @"Z:\impossible_path\not_really.exe", IsValid = true }; - var service = new ProcessExecutionService(_logServiceMock.Object); + var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object); - service.Execute(tool); + await service.ExecuteAsync(tool); - // Opening a non-existent file should throw and be caught _logServiceMock.Verify(x => x.Error(It.Is(s => s.Contains("FailTool") && s.Contains("失败"))), Times.Once); } [Fact] - public void Execute_Success_LogsInfo() + public async Task Execute_Success_LogsInfo() { - // Use a command that definitely exists on Windows var tool = new ToolItem { Name = "记事本", ExecutablePath = "notepad.exe", IsValid = true }; - var service = new ProcessExecutionService(_logServiceMock.Object); + var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object); - service.Execute(tool); + await service.ExecuteAsync(tool); _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains("成功启动") && s.Contains("记事本"))), Times.Once); } + + [Fact] + public async Task Execute_Group_BatchExecutesSubTools() + { + var sub1 = new ToolItem { Id = "s1", Name = "Sub1", ExecutablePath = "cmd.exe", IsValid = true }; + var sub2 = new ToolItem { Id = "s2", Name = "Sub2", ExecutablePath = "cmd.exe", IsValid = true }; + _config.Tools.Add(sub1); + _config.Tools.Add(sub2); + + var group = new ToolItem + { + Name = "MyGroup", + IsGroup = true, + SubToolIds = new List { "s1", "s2" }, + IsValid = true + }; + var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object); + + await service.ExecuteAsync(group); + + _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains("开始启动组合"))), Times.Once); + _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains("组合") && s.Contains("启动完成"))), Times.Once); + _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains("成功启动") && s.Contains("Sub1"))), Times.Once); + } + + [Fact] + public async Task Execute_Group_OrphanSubIds_Skipped() + { + var sub1 = new ToolItem { Id = "s1", Name = "Sub1", ExecutablePath = "cmd.exe", IsValid = true }; + _config.Tools.Add(sub1); + + var group = new ToolItem + { + Name = "Group", + IsGroup = true, + SubToolIds = new List { "s1", "missing_id" }, + IsValid = true + }; + var service = new ProcessExecutionService(_logServiceMock.Object, _dataServiceMock.Object); + + await service.ExecuteAsync(group); + + _logServiceMock.Verify(x => x.Warning(It.Is(s => s.Contains("找不到 ID") && s.Contains("missing_id"))), Times.Once); + _logServiceMock.Verify(x => x.Info(It.Is(s => s.Contains("成功启动") && s.Contains("Sub1"))), Times.Once); + } } diff --git a/PersonalToolBox.Tests/ViewModels/GroupEditViewModelTests.cs b/PersonalToolBox.Tests/ViewModels/GroupEditViewModelTests.cs new file mode 100644 index 0000000..0aa5c1c --- /dev/null +++ b/PersonalToolBox.Tests/ViewModels/GroupEditViewModelTests.cs @@ -0,0 +1,97 @@ +using Moq; +using PersonalToolBox.Models; +using PersonalToolBox.Services; +using PersonalToolBox.ViewModels; + +namespace PersonalToolBox.Tests.ViewModels; + +public class GroupEditViewModelTests +{ + private readonly Mock _dataServiceMock = new(); + private readonly Mock _logServiceMock = new(); + private readonly AppConfig _config; + + public GroupEditViewModelTests() + { + _config = new AppConfig + { + Tools = + [ + new() { Id = "t1", Name = "VS Code", IsValid = true }, + new() { Id = "t2", Name = "Postman", IsValid = true }, + new() { Id = "t3", Name = "SomeGroup", IsGroup = true, SubToolIds = new List { "t1" }, IsValid = true } + ] + }; + _dataServiceMock.Setup(d => d.Config).Returns(_config); + } + + [Fact] + public void Constructor_AddMode_LoadsOnlyNonGroupTools() + { + var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object); + + Assert.Equal("添加组合", vm.WindowTitle); + Assert.Equal(2, vm.AvailableTools.Count); // t1, t2 only (t3 is a group) + } + + [Fact] + public void Constructor_EditMode_PreselectedTools() + { + var group = new ToolItem + { + Name = "ExistingGroup", + IsGroup = true, + SubToolIds = new List { "t1" } + }; + + var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object, group); + + Assert.Equal("编辑组合", vm.WindowTitle); + Assert.Equal("ExistingGroup", vm.Name); + Assert.True(vm.AvailableTools.First(t => t.Tool.Id == "t1").IsSelected); + Assert.False(vm.AvailableTools.First(t => t.Tool.Id == "t2").IsSelected); + } + + [Fact] + public void Save_EmptyName_LogsWarning() + { + var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object); + + vm.SaveCommand.Execute(null); + + _logServiceMock.Verify(x => x.Warning(It.Is(s => s.Contains("名称不能为空"))), Times.Once); + } + + [Fact] + public void Save_AddMode_CreatesGroupAndCloses() + { + var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object) + { + Name = "NewGroup" + }; + vm.AvailableTools.First(t => t.Tool.Id == "t1").IsSelected = true; + bool? closeResult = null; + vm.CloseAction = (r) => closeResult = r; + + vm.SaveCommand.Execute(null); + + Assert.True(vm.Saved); + Assert.True(closeResult); + Assert.Single(_config.Tools.Where(t => t.IsGroup && t.Name == "NewGroup")); + Assert.Contains("t1", _config.Tools.First(t => t.Name == "NewGroup").SubToolIds); + _dataServiceMock.Verify(x => x.Save(), Times.Once); + } + + [Fact] + public void Cancel_ClosesWithoutSaving() + { + var vm = new GroupEditViewModel(_dataServiceMock.Object, _logServiceMock.Object); + bool? closeResult = null; + vm.CloseAction = (r) => closeResult = r; + + vm.CancelCommand.Execute(null); + + Assert.False(vm.Saved); + Assert.False(closeResult); + } +} diff --git a/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs b/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs index b888ff7..4634045 100644 --- a/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs +++ b/PersonalToolBox.Tests/ViewModels/MainViewModelTests.cs @@ -165,7 +165,7 @@ public class MainViewModelTests vm.ExecuteToolCommand.Execute(tool); - _processServiceMock.Verify(x => x.Execute(tool), Times.Once); + _processServiceMock.Verify(x => x.ExecuteAsync(tool), Times.Once); } [Fact] @@ -173,7 +173,7 @@ public class MainViewModelTests { var vm = CreateViewModel(); vm.ExecuteToolCommand.Execute(null); - _processServiceMock.Verify(x => x.Execute(It.IsAny()), Times.Never); + _processServiceMock.Verify(x => x.ExecuteAsync(It.IsAny()), Times.Never); } [Fact] diff --git a/PersonalToolBox/App.xaml.cs b/PersonalToolBox/App.xaml.cs index 19dc181..f1ce50f 100644 --- a/PersonalToolBox/App.xaml.cs +++ b/PersonalToolBox/App.xaml.cs @@ -81,6 +81,7 @@ public partial class App : System.Windows.Application services.AddSingleton(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddSingleton(); } diff --git a/PersonalToolBox/Helpers/HotKeyManager.cs b/PersonalToolBox/Helpers/HotKeyManager.cs index 00a4bd7..02da5b2 100644 --- a/PersonalToolBox/Helpers/HotKeyManager.cs +++ b/PersonalToolBox/Helpers/HotKeyManager.cs @@ -108,7 +108,7 @@ public class HotKeyManager if (_hotkeyMap.TryGetValue(id, out var tool)) { _logService.Info($"通过快捷键启动: {tool.Name}"); - _processService.Execute(tool); + _ = _processService.ExecuteAsync(tool); } return true; } diff --git a/PersonalToolBox/Models/ToolItem.cs b/PersonalToolBox/Models/ToolItem.cs index 01c8603..df0324c 100644 --- a/PersonalToolBox/Models/ToolItem.cs +++ b/PersonalToolBox/Models/ToolItem.cs @@ -47,4 +47,14 @@ public class ToolItem /// [JsonIgnore] public bool IsValid { get; set; } = true; + + /// + /// 是否为组合卡片(一键多开) + /// + public bool IsGroup { get; set; } + + /// + /// 当 IsGroup 为 true 时,存储需批量启动的子工具 ID 列表 + /// + public List SubToolIds { get; set; } = new(); } diff --git a/PersonalToolBox/Services/IProcessExecutionService.cs b/PersonalToolBox/Services/IProcessExecutionService.cs index 7585131..9cff648 100644 --- a/PersonalToolBox/Services/IProcessExecutionService.cs +++ b/PersonalToolBox/Services/IProcessExecutionService.cs @@ -8,8 +8,7 @@ namespace PersonalToolBox.Services; public interface IProcessExecutionService { /// - /// 执行指定的工具项 + /// 执行指定的工具项(含组合批量启动) /// - /// 要执行的工具项 - void Execute(ToolItem tool); + Task ExecuteAsync(ToolItem tool); } diff --git a/PersonalToolBox/Services/ProcessExecutionService.cs b/PersonalToolBox/Services/ProcessExecutionService.cs index 0156ecb..8dc74d1 100644 --- a/PersonalToolBox/Services/ProcessExecutionService.cs +++ b/PersonalToolBox/Services/ProcessExecutionService.cs @@ -5,17 +5,20 @@ namespace PersonalToolBox.Services; /// /// 进程执行服务,负责启动外部工具进程并处理异常 +/// 支持一键多开:当 IsGroup 为 true 时批量启动子工具 /// public class ProcessExecutionService : IProcessExecutionService { private readonly ILogService _logService; + private readonly IDataService _dataService; - public ProcessExecutionService(ILogService logService) + public ProcessExecutionService(ILogService logService, IDataService dataService) { _logService = logService; + _dataService = dataService; } - public void Execute(ToolItem tool) + public async Task ExecuteAsync(ToolItem tool) { if (tool == null) { @@ -23,12 +26,63 @@ public class ProcessExecutionService : IProcessExecutionService return; } + // 组合卡片:遍历子工具列表逐一启动 + if (tool.IsGroup) + { + await ExecuteGroupAsync(tool); + return; + } + + // 普通工具:直接启动 if (!tool.IsValid) { _logService.Warning($"无法运行工具 \"{tool.Name}\",路径失效: {tool.ExecutablePath}"); return; } + LaunchSingleTool(tool); + } + + /// + /// 批量启动组合中的所有子工具,每次间隔 500ms 防止系统卡顿 + /// + private async Task ExecuteGroupAsync(ToolItem group) + { + var subTools = new List(); + + foreach (var subId in group.SubToolIds) + { + var subTool = _dataService.Config.Tools.FirstOrDefault(t => t.Id == subId); + if (subTool == null) + { + _logService.Warning($"组合启动跳过:找不到 ID 为 {subId} 的工具"); + continue; + } + subTools.Add(subTool); + } + + _logService.Info($"开始启动组合 \"{group.Name}\",共 {subTools.Count} 个工具"); + + foreach (var subTool in subTools) + { + if (!subTool.IsValid) + { + _logService.Warning($"组合启动跳过 \"{subTool.Name}\",路径失效: {subTool.ExecutablePath}"); + continue; + } + + LaunchSingleTool(subTool); + await Task.Delay(500); + } + + _logService.Info($"组合 \"{group.Name}\" 启动完成"); + } + + /// + /// 启动单个工具进程 + /// + private void LaunchSingleTool(ToolItem tool) + { try { var startInfo = new ProcessStartInfo diff --git a/PersonalToolBox/ViewModels/GroupEditViewModel.cs b/PersonalToolBox/ViewModels/GroupEditViewModel.cs new file mode 100644 index 0000000..347bdb3 --- /dev/null +++ b/PersonalToolBox/ViewModels/GroupEditViewModel.cs @@ -0,0 +1,144 @@ +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using PersonalToolBox.Models; +using PersonalToolBox.Services; + +namespace PersonalToolBox.ViewModels; + +/// +/// 组合编辑窗口 ViewModel,管理一键多开组合的创建与编辑 +/// +public partial class GroupEditViewModel : ObservableObject +{ + private readonly IDataService _dataService; + private readonly ILogService _logService; + private readonly ToolItem? _editingGroup; + + // ──────── 可观察属性 ──────── + + [ObservableProperty] + private string _windowTitle = "添加组合"; + + [ObservableProperty] + private string _name = string.Empty; + + [ObservableProperty] + private string _hotKey = string.Empty; + + [ObservableProperty] + private Category? _selectedCategory; + + /// + /// 可供勾选的普通工具列表(IsGroup == false) + /// + public ObservableCollection AvailableTools { get; } = new(); + + /// + /// 分类下拉列表 + /// + public ObservableCollection Categories { get; } = new(); + + public Action? CloseAction { get; set; } + public bool Saved { get; private set; } + + // ──────── 构造 ──────── + + public GroupEditViewModel(IDataService dataService, ILogService logService, ToolItem? groupToEdit = null) + { + _dataService = dataService; + _logService = logService; + _editingGroup = groupToEdit; + + foreach (var cat in _dataService.Config.Categories) + Categories.Add(cat); + + // 加载所有普通工具(非组合) + var selectedIds = _editingGroup?.SubToolIds ?? new List(); + foreach (var tool in _dataService.Config.Tools.Where(t => !t.IsGroup)) + { + AvailableTools.Add(new SelectableTool + { + Tool = tool, + IsSelected = selectedIds.Contains(tool.Id) + }); + } + + if (groupToEdit != null) + { + WindowTitle = "编辑组合"; + Name = groupToEdit.Name; + HotKey = groupToEdit.HotKey; + SelectedCategory = Categories.FirstOrDefault(c => c.Id == groupToEdit.CategoryId); + } + } + + // ──────── 命令 ──────── + + [RelayCommand] + private void Save() + { + try + { + if (string.IsNullOrWhiteSpace(Name)) + { + _logService.Warning("组合名称不能为空"); + return; + } + + var selectedIds = AvailableTools + .Where(t => t.IsSelected) + .Select(t => t.Tool.Id) + .ToList(); + + if (_editingGroup != null) + { + _editingGroup.Name = Name.Trim(); + _editingGroup.HotKey = HotKey.Trim(); + _editingGroup.CategoryId = SelectedCategory?.Id ?? string.Empty; + _editingGroup.SubToolIds = selectedIds; + _editingGroup.IsValid = true; + _logService.Info($"已更新组合: {Name.Trim()}(包含 {selectedIds.Count} 个工具)"); + } + else + { + _dataService.Config.Tools.Add(new ToolItem + { + Name = Name.Trim(), + HotKey = HotKey.Trim(), + CategoryId = SelectedCategory?.Id ?? string.Empty, + IsGroup = true, + SubToolIds = selectedIds, + IsValid = true + }); + _logService.Info($"已添加组合: {Name.Trim()}(包含 {selectedIds.Count} 个工具)"); + } + + _dataService.Save(); + Saved = true; + CloseAction?.Invoke(true); + } + catch (Exception ex) + { + _logService.Error($"保存组合失败: {ex.Message}"); + } + } + + [RelayCommand] + private void Cancel() + { + CloseAction?.Invoke(false); + } +} + +/// +/// 可勾选的工具项(用于 CheckBox 列表绑定) +/// +public partial class SelectableTool : ObservableObject +{ + public ToolItem Tool { get; set; } = null!; + + [ObservableProperty] + private bool _isSelected; +} diff --git a/PersonalToolBox/ViewModels/MainViewModel.cs b/PersonalToolBox/ViewModels/MainViewModel.cs index 0f56187..0227d68 100644 --- a/PersonalToolBox/ViewModels/MainViewModel.cs +++ b/PersonalToolBox/ViewModels/MainViewModel.cs @@ -143,10 +143,10 @@ public partial class MainViewModel : ObservableObject /// 执行工具(双击卡片或右键菜单) /// [RelayCommand] - private void ExecuteTool(ToolItem? tool) + private async Task ExecuteTool(ToolItem? tool) { if (tool == null) return; - _processService.Execute(tool); + await _processService.ExecuteAsync(tool); } // ───────────────────────────── 分类管理命令 ───────────────────────────── @@ -206,6 +206,38 @@ public partial class MainViewModel : ObservableObject RefreshData(); } + // ───────────────────────────── 组合管理命令 ───────────────────────────── + + /// + /// 添加组合 + /// + [RelayCommand] + private void AddGroup() + { + var vm = _serviceProvider.GetRequiredService(); + var window = new GroupEditWindow(vm); + window.ShowDialog(); + + if (vm.Saved) RefreshData(); + } + + /// + /// 编辑组合 + /// + [RelayCommand] + private void EditGroup(ToolItem tool) + { + if (tool == null || !tool.IsGroup) return; + + var dataService = _serviceProvider.GetRequiredService(); + var logService = _serviceProvider.GetRequiredService(); + var editVm = new GroupEditViewModel(dataService, logService, tool); + var window = new GroupEditWindow(editVm); + window.ShowDialog(); + + if (editVm.Saved) RefreshData(); + } + // ───────────────────────────── 数据刷新 ───────────────────────────── /// diff --git a/PersonalToolBox/Views/GroupEditWindow.xaml b/PersonalToolBox/Views/GroupEditWindow.xaml new file mode 100644 index 0000000..af6a5ef --- /dev/null +++ b/PersonalToolBox/Views/GroupEditWindow.xaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +