Phase 2-3: UI layout, theme switching, CRUD tools, process execution

- Phase 2: MainWindow 3-section layout (sidebar/content/log bar), Dark/Light theme with ThemeHelper, MainViewModel with ObservableProperty/RelayCommand, tool card filtering by search + category

- Phase 3: ToolEditWindow for add/edit tools, ProcessExecutionService (Process.Start + error handling), double-click + right-click context menu (run/edit), path browse dialog

- Bugfix: ContextMenu commands now use PlacementTarget.Tag binding (ContextMenu in separate visual tree)

- Bugfix: StaticResource converters moved to XAML before DataTemplate to fix XamlParseException on tool card render

- Bugfix: Pure filenames (no path separators) treated as PATH commands, not marked invalid

- Bugfix: RefreshData preserves SelectedCategory; Load() catches all exceptions; Save() wrapped in try-catch; auto-scroll log to newest entry

- Tests: xUnit project with 55 tests covering models, services, converters, and view models
This commit is contained in:
2026-05-09 21:52:31 +08:00
parent 752f09a7e4
commit 71be5da54b
22 changed files with 1991 additions and 22 deletions

View File

@@ -0,0 +1,118 @@
using System.Text.Json;
using PersonalToolBox.Models;
namespace PersonalToolBox.Tests.Models;
public class ModelSerializationTests
{
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
[Fact]
public void AppConfig_Serialize_ProducesValidJson()
{
var config = new AppConfig
{
Theme = "Dark",
AutoStart = true,
Categories =
[
new() { Id = "1", Name = "开发工具" },
new() { Id = "2", Name = "常用脚本" }
],
Tools =
[
new()
{
Id = "tool-1",
Name = "Postman",
IconCode = "f0c2",
ExecutablePath = @"D:\tools\postman.exe",
Arguments = "",
CategoryId = "1",
HotKey = "Ctrl+Alt+P"
}
]
};
var json = JsonSerializer.Serialize(config, Options);
var deserialized = JsonSerializer.Deserialize<AppConfig>(json, Options);
Assert.NotNull(deserialized);
Assert.Equal("Dark", deserialized.Theme);
Assert.True(deserialized.AutoStart);
Assert.Equal(2, deserialized.Categories.Count);
Assert.Single(deserialized.Tools);
}
[Fact]
public void ToolItem_IsValid_NotIncludedInJson()
{
var tool = new ToolItem
{
Name = "TestTool",
ExecutablePath = @"C:\nonexistent.exe",
IsValid = false
};
var json = JsonSerializer.Serialize(tool, Options);
Assert.DoesNotContain("IsValid", json);
}
[Fact]
public void ToolItem_DefaultValues_AreCorrect()
{
var tool = new ToolItem();
Assert.NotEmpty(tool.Id);
Assert.Empty(tool.Name);
Assert.True(tool.IsValid);
}
[Fact]
public void Category_DefaultId_IsNotEmpty()
{
var cat = new Category();
Assert.NotEmpty(cat.Id);
}
[Fact]
public void AppConfig_DefaultValues_AreCorrect()
{
var config = new AppConfig();
Assert.Equal("Dark", config.Theme);
Assert.False(config.AutoStart);
Assert.NotNull(config.Categories);
Assert.NotNull(config.Tools);
Assert.Empty(config.Categories);
Assert.Empty(config.Tools);
}
[Fact]
public void Deserialize_EmptyJson_ProducesValidConfig()
{
var json = "{}";
var config = JsonSerializer.Deserialize<AppConfig>(json, Options);
Assert.NotNull(config);
Assert.Equal("Dark", config.Theme);
Assert.False(config.AutoStart);
Assert.Empty(config.Categories);
Assert.Empty(config.Tools);
}
[Fact]
public void Deserialize_WithUnknownProperty_IgnoresIt()
{
var json = """{"unknownField": 123, "Theme": "Light"}""";
var config = JsonSerializer.Deserialize<AppConfig>(json, Options);
Assert.NotNull(config);
Assert.Equal("Light", config.Theme);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PersonalToolBox\PersonalToolBox.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,194 @@
using System.IO;
using Moq;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
namespace PersonalToolBox.Tests.Services;
public class JsonDataServiceTests
{
private readonly Mock<ILogService> _logServiceMock = new();
private readonly string _testDir;
public JsonDataServiceTests()
{
_testDir = Path.Combine(Path.GetTempPath(), $"toolbox_test_{Guid.NewGuid():N}");
Directory.CreateDirectory(_testDir);
}
[Fact]
public void Load_FileNotExists_CreatesDefaultConfig()
{
// Use a path that doesn't exist
var configPath = Path.Combine(_testDir, "nonexistent.json");
var service = CreateService(configPath);
service.Load();
Assert.NotNull(service.Config);
Assert.Equal("Dark", service.Config.Theme);
Assert.Empty(service.Config.Tools);
// Verify warning was logged
VerifyLog(LogLevel.Info, "默认配置");
}
[Fact]
public void Save_ThenLoad_PreservesData()
{
var configPath = Path.Combine(_testDir, "config.json");
var service = CreateService(configPath);
service.Config.Theme = "Light";
service.Config.Categories.Add(new Category { Id = "1", Name = "dev" });
service.Config.Tools.Add(new ToolItem
{
Name = "TestTool",
ExecutablePath = Path.Combine(_testDir, "test.exe")
});
service.Save();
// Load into a new service
var service2 = CreateService(configPath);
service2.Load();
Assert.Equal("Light", service2.Config.Theme);
Assert.Single(service2.Config.Categories);
Assert.Equal("dev", service2.Config.Categories[0].Name);
Assert.Single(service2.Config.Tools);
Assert.Equal("TestTool", service2.Config.Tools[0].Name);
}
[Fact]
public void Load_InvalidPath_ToolSetToInvalid()
{
var configPath = Path.Combine(_testDir, "config.json");
var service = CreateService(configPath);
service.Config.Tools.Add(new ToolItem
{
Name = "MissingTool",
ExecutablePath = @"C:\definitely\does\not\exist.exe"
});
service.Save();
var service2 = CreateService(configPath);
service2.Load();
var tool = service2.Config.Tools[0];
Assert.False(tool.IsValid);
VerifyLog(LogLevel.Warning, "路径失效");
}
[Fact]
public void Load_ValidPath_ToolRemainsValid()
{
var configPath = Path.Combine(_testDir, "config.json");
var service = CreateService(configPath);
// Create a real temp file
var tempFile = Path.Combine(_testDir, "real_tool.exe");
File.WriteAllText(tempFile, "dummy");
service.Config.Tools.Add(new ToolItem
{
Name = "RealTool",
ExecutablePath = tempFile
});
service.Save();
var service2 = CreateService(configPath);
service2.Load();
var tool = service2.Config.Tools[0];
Assert.True(tool.IsValid);
}
[Fact]
public void Load_UrlPath_AlwaysValid()
{
var configPath = Path.Combine(_testDir, "config.json");
var service = CreateService(configPath);
service.Config.Tools.Add(new ToolItem
{
Name = "WebTool",
ExecutablePath = "https://example.com/tool"
});
service.Save();
var service2 = CreateService(configPath);
service2.Load();
var tool = service2.Config.Tools[0];
Assert.True(tool.IsValid);
}
[Fact]
public void Load_EmptyExecutablePath_SkipsValidation()
{
var configPath = Path.Combine(_testDir, "config.json");
var service = CreateService(configPath);
service.Config.Tools.Add(new ToolItem
{
Name = "NoPathTool",
ExecutablePath = ""
});
service.Save();
var service2 = CreateService(configPath);
service2.Load();
var tool = service2.Config.Tools[0];
Assert.True(tool.IsValid);
}
[Fact]
public void Load_CorruptedJson_LogsErrorAndUsesDefaults()
{
var configPath = Path.Combine(_testDir, "config.json");
File.WriteAllText(configPath, "this is not json {{{");
var service = CreateService(configPath);
service.Load();
Assert.NotNull(service.Config);
Assert.Equal("Dark", service.Config.Theme);
VerifyLog(LogLevel.Error, "解析失败");
}
// ───────────────────────────── helpers ─────────────────────────────
private JsonDataService CreateService(string filePath)
{
var service = new JsonDataService(_logServiceMock.Object);
// Replace the file path via reflection
typeof(JsonDataService)
.GetField("_filePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
.SetValue(service, filePath);
return service;
}
private void VerifyLog(LogLevel level, string fragment)
{
// 根据日志级别验证对应方法被调用
switch (level)
{
case LogLevel.Info:
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains(fragment))), Times.AtLeastOnce);
break;
case LogLevel.Warning:
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains(fragment))), Times.AtLeastOnce);
break;
case LogLevel.Error:
_logServiceMock.Verify(x => x.Error(It.Is<string>(s => s.Contains(fragment))), Times.AtLeastOnce);
break;
}
}
}

View File

@@ -0,0 +1,96 @@
using PersonalToolBox.Models;
using PersonalToolBox.Services;
namespace PersonalToolBox.Tests.Services;
public class LogServiceTests
{
private readonly LogService _logService = new();
[Fact]
public void Info_AddsLogEntry()
{
_logService.Info("test message");
Assert.Single(_logService.Logs);
var entry = _logService.Logs[0];
Assert.Equal(LogLevel.Info, entry.Level);
Assert.Equal("test message", entry.Content);
Assert.True((DateTime.Now - entry.Timestamp).TotalSeconds < 2);
}
[Fact]
public void Warning_AddsLogEntry_WithWarningLevel()
{
_logService.Warning("disk full");
Assert.Single(_logService.Logs);
Assert.Equal(LogLevel.Warning, _logService.Logs[0].Level);
Assert.Equal("disk full", _logService.Logs[0].Content);
}
[Fact]
public void Error_AddsLogEntry_WithErrorLevel()
{
_logService.Error("crash detected");
Assert.Single(_logService.Logs);
Assert.Equal(LogLevel.Error, _logService.Logs[0].Level);
Assert.Equal("crash detected", _logService.Logs[0].Content);
}
[Fact]
public void MultipleLogs_AreAddedInOrder()
{
_logService.Info("first");
_logService.Warning("second");
_logService.Error("third");
Assert.Equal(3, _logService.Logs.Count);
Assert.Equal(LogLevel.Info, _logService.Logs[0].Level);
Assert.Equal(LogLevel.Warning, _logService.Logs[1].Level);
Assert.Equal(LogLevel.Error, _logService.Logs[2].Level);
}
[Fact]
public void Clear_RemovesAllEntries()
{
_logService.Info("msg");
_logService.Info("msg");
Assert.Equal(2, _logService.Logs.Count);
_logService.Clear();
Assert.Empty(_logService.Logs);
}
[Fact]
public void LogEntry_ToString_FormatsCorrectly()
{
var entry = new LogEntry
{
Timestamp = new DateTime(2026, 5, 9, 20, 0, 0),
Level = LogLevel.Warning,
Content = "path not found"
};
var str = entry.ToString();
Assert.Contains("[2026-05-09 20:00:00]", str);
Assert.Contains("[警告]", str);
Assert.Contains("path not found", str);
}
[Fact]
public void LogEntry_LevelText_ProducesChineseLabels()
{
var info = new LogEntry { Level = LogLevel.Info };
var warn = new LogEntry { Level = LogLevel.Warning };
var err = new LogEntry { Level = LogLevel.Error };
Assert.Equal("[信息]", info.LevelText);
Assert.Equal("[警告]", warn.LevelText);
Assert.Equal("[错误]", err.LevelText);
}
}

View File

@@ -0,0 +1,70 @@
using Moq;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
namespace PersonalToolBox.Tests.Services;
public class ProcessExecutionServiceTests
{
private readonly Mock<ILogService> _logServiceMock = new();
[Fact]
public void Execute_NullTool_LogsError()
{
var service = new ProcessExecutionService(_logServiceMock.Object);
service.Execute(null!);
_logServiceMock.Verify(x => x.Error(It.Is<string>(s => s.Contains("空工具项"))), Times.Once);
}
[Fact]
public void Execute_InvalidTool_LogsWarning()
{
var tool = new ToolItem
{
Name = "BadTool",
ExecutablePath = @"C:\nonexistent.exe",
IsValid = false
};
var service = new ProcessExecutionService(_logServiceMock.Object);
service.Execute(tool);
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("无法运行") && s.Contains("BadTool"))), Times.Once);
}
[Fact]
public void Execute_ProcessFails_LogsError()
{
var tool = new ToolItem
{
Name = "FailTool",
ExecutablePath = @"Z:\impossible_path\not_really.exe",
IsValid = true
};
var service = new ProcessExecutionService(_logServiceMock.Object);
service.Execute(tool);
// Opening a non-existent file should throw and be caught
_logServiceMock.Verify(x => x.Error(It.Is<string>(s => s.Contains("FailTool") && s.Contains("失败"))), Times.Once);
}
[Fact]
public void 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);
service.Execute(tool);
_logServiceMock.Verify(x => x.Info(It.Is<string>(s => s.Contains("成功启动") && s.Contains("记事本"))), Times.Once);
}
}

View File

@@ -0,0 +1,186 @@
using Moq;
using Microsoft.Extensions.DependencyInjection;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
using PersonalToolBox.ViewModels;
namespace PersonalToolBox.Tests.ViewModels;
public class MainViewModelTests
{
private readonly Mock<ILogService> _logServiceMock = new();
private readonly Mock<IDataService> _dataServiceMock = new();
private readonly Mock<IProcessExecutionService> _processServiceMock = new();
private readonly Mock<IServiceProvider> _serviceProviderMock = new();
private readonly AppConfig _config;
public MainViewModelTests()
{
_config = new AppConfig
{
Theme = "Dark",
Categories =
[
new() { Id = "1", Name = "开发工具" },
new() { Id = "2", Name = "系统工具" }
],
Tools =
[
new() { Id = "t1", Name = "VS Code", CategoryId = "1", IsValid = true },
new() { Id = "t2", Name = "Postman", CategoryId = "1", IsValid = true },
new() { Id = "t3", Name = "任务管理器", CategoryId = "2", IsValid = true },
new() { Id = "t4", Name = "失效工具", CategoryId = "1", ExecutablePath = @"C:\missing.exe", IsValid = false }
]
};
_dataServiceMock.Setup(d => d.Config).Returns(_config);
}
private MainViewModel CreateViewModel() =>
new(_logServiceMock.Object, _dataServiceMock.Object, _processServiceMock.Object, _serviceProviderMock.Object);
[Fact]
public void Constructor_LoadsCategories_IncludingAll()
{
var vm = CreateViewModel();
Assert.Equal(3, vm.Categories.Count);
Assert.Equal("全部", vm.Categories[0].Name);
Assert.Equal("开发工具", vm.Categories[1].Name);
Assert.Equal("系统工具", vm.Categories[2].Name);
}
[Fact]
public void Constructor_LoadsAllTools()
{
var vm = CreateViewModel();
Assert.Equal(4, vm.Tools.Count);
}
[Fact]
public void Default_CategoryIsAll_ShowsAllTools()
{
var vm = CreateViewModel();
Assert.Equal(4, vm.FilteredTools.Count);
}
[Fact]
public void SelectCategory_FiltersByCategory()
{
var vm = CreateViewModel();
vm.SelectedCategory = vm.Categories[1];
Assert.Equal(3, vm.FilteredTools.Count);
}
[Fact]
public void SearchText_FiltersByName()
{
var vm = CreateViewModel();
vm.SearchText = "post";
Assert.Single(vm.FilteredTools);
Assert.Equal("Postman", vm.FilteredTools[0].Name);
}
[Fact]
public void SearchText_CaseInsensitive()
{
var vm = CreateViewModel();
vm.SearchText = "POSTMAN";
Assert.Single(vm.FilteredTools);
Assert.Equal("Postman", vm.FilteredTools[0].Name);
}
[Fact]
public void SearchText_And_Category_FilterCombined()
{
var vm = CreateViewModel();
vm.SelectedCategory = vm.Categories[1];
vm.SearchText = "code";
Assert.Single(vm.FilteredTools);
Assert.Equal("VS Code", vm.FilteredTools[0].Name);
}
[Fact]
public void SearchText_Empty_ShowsAllInCategory()
{
var vm = CreateViewModel();
vm.SelectedCategory = vm.Categories[2];
vm.SearchText = "";
Assert.Single(vm.FilteredTools);
Assert.Equal("任务管理器", vm.FilteredTools[0].Name);
}
[Fact]
public void SearchText_NoMatch_ReturnsEmpty()
{
var vm = CreateViewModel();
vm.SearchText = "nonexistent";
Assert.Empty(vm.FilteredTools);
}
[Fact]
public void InvalidTools_StillAppearInFilteredResults()
{
var vm = CreateViewModel();
vm.SelectedCategory = vm.Categories[1];
var invalid = vm.FilteredTools.FirstOrDefault(t => t.Name == "失效工具");
Assert.NotNull(invalid);
Assert.False(invalid.IsValid);
}
[Fact]
public void ClearLogsCommand_CallsLogServiceClear()
{
var vm = CreateViewModel();
vm.ClearLogsCommand.Execute(null);
_logServiceMock.Verify(x => x.Clear(), Times.Once);
}
[Fact]
public void ToggleTheme_SwitchesDarkToLight()
{
var vm = CreateViewModel();
Assert.Equal("Dark", vm.CurrentTheme);
vm.ToggleThemeCommand.Execute(null);
Assert.Equal("Light", vm.CurrentTheme);
}
[Fact]
public void ToggleTheme_CallsSave()
{
var vm = CreateViewModel();
vm.ToggleThemeCommand.Execute(null);
_dataServiceMock.Verify(x => x.Save(), Times.Once);
}
[Fact]
public void ExecuteTool_CallsProcessService()
{
var vm = CreateViewModel();
var tool = vm.Tools[0];
vm.ExecuteToolCommand.Execute(tool);
_processServiceMock.Verify(x => x.Execute(tool), Times.Once);
}
[Fact]
public void ExecuteTool_Null_DoesNotCallProcessService()
{
var vm = CreateViewModel();
vm.ExecuteToolCommand.Execute(null);
_processServiceMock.Verify(x => x.Execute(It.IsAny<ToolItem>()), Times.Never);
}
[Fact]
public void RefreshData_ReloadsFromConfig()
{
var vm = CreateViewModel();
_config.Tools.Add(new ToolItem { Name = "NewTool" });
vm.RefreshData();
Assert.Equal(5, vm.Tools.Count);
}
}

View File

@@ -0,0 +1,151 @@
using Moq;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
using PersonalToolBox.ViewModels;
namespace PersonalToolBox.Tests.ViewModels;
public class ToolEditViewModelTests
{
private readonly Mock<IDataService> _dataServiceMock = new();
private readonly Mock<ILogService> _logServiceMock = new();
private readonly AppConfig _config;
public ToolEditViewModelTests()
{
_config = new AppConfig
{
Categories =
[
new() { Id = "1", Name = "开发工具" }
]
};
_dataServiceMock.Setup(d => d.Config).Returns(_config);
}
[Fact]
public void Constructor_AddMode_SetsTitle()
{
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
Assert.Equal("添加工具", vm.WindowTitle);
Assert.Empty(vm.Name);
Assert.Empty(vm.ExecutablePath);
}
[Fact]
public void Constructor_EditMode_SetsTitleAndFields()
{
var tool = new ToolItem
{
Name = "MyTool",
ExecutablePath = @"C:\test.exe",
Arguments = "/arg",
HotKey = "Ctrl+T",
CategoryId = "1"
};
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object, tool);
Assert.Equal("编辑工具", vm.WindowTitle);
Assert.Equal("MyTool", vm.Name);
Assert.Equal(@"C:\test.exe", vm.ExecutablePath);
Assert.Equal("/arg", vm.Arguments);
Assert.Equal("Ctrl+T", vm.HotKey);
}
[Fact]
public void Save_EmptyName_LogsWarning()
{
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
{
ExecutablePath = @"C:\test.exe"
};
vm.SaveCommand.Execute(null);
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("名称不能为空"))), Times.Once);
}
[Fact]
public void Save_EmptyPath_LogsWarning()
{
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
{
Name = "Test"
};
vm.SaveCommand.Execute(null);
_logServiceMock.Verify(x => x.Warning(It.Is<string>(s => s.Contains("路径不能为空"))), Times.Once);
}
[Fact]
public void Save_AddMode_AddsToolAndCloses()
{
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object)
{
Name = "NewTool",
ExecutablePath = @"C:\test.exe"
};
bool? closeResult = null;
vm.CloseAction = (r) => closeResult = r;
vm.SaveCommand.Execute(null);
Assert.True(vm.Saved);
Assert.True(closeResult);
Assert.Single(_config.Tools);
Assert.Equal("NewTool", _config.Tools[0].Name);
_dataServiceMock.Verify(x => x.Save(), Times.Once);
}
[Fact]
public void Save_EditMode_UpdatesToolAndCloses()
{
var tool = new ToolItem
{
Name = "OldName",
ExecutablePath = @"C:\old.exe"
};
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object, tool)
{
Name = "NewName",
ExecutablePath = @"C:\new.exe"
};
bool? closeResult = null;
vm.CloseAction = (r) => closeResult = r;
vm.SaveCommand.Execute(null);
Assert.True(vm.Saved);
Assert.True(closeResult);
Assert.Equal("NewName", tool.Name);
Assert.Equal(@"C:\new.exe", tool.ExecutablePath);
_dataServiceMock.Verify(x => x.Save(), Times.Once);
}
[Fact]
public void Cancel_ClosesWithoutSaving()
{
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
bool? closeResult = null;
vm.CloseAction = (r) => closeResult = r;
vm.CancelCommand.Execute(null);
Assert.False(vm.Saved);
Assert.False(closeResult);
_dataServiceMock.Verify(x => x.Save(), Times.Never);
}
[Fact]
public void Constructor_LoadsCategories()
{
var vm = new ToolEditViewModel(_dataServiceMock.Object, _logServiceMock.Object);
Assert.Single(vm.Categories);
Assert.Equal("开发工具", vm.Categories[0].Name);
}
}

View File

@@ -0,0 +1,67 @@
using System.Globalization;
using PersonalToolBox.Views;
namespace PersonalToolBox.Tests.Views;
public class ConverterTests
{
[Fact]
public void BoolToOpacityConverter_True_ReturnsOne()
{
var converter = new BoolToOpacityConverter();
var result = converter.Convert(true, typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(1.0, result);
}
[Fact]
public void BoolToOpacityConverter_False_ReturnsZeroPointFour()
{
var converter = new BoolToOpacityConverter();
var result = converter.Convert(false, typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(0.4, result);
}
[Fact]
public void BoolToOpacityConverter_NonBool_ReturnsZeroPointFour()
{
var converter = new BoolToOpacityConverter();
var result = converter.Convert("not a bool", typeof(double), null, CultureInfo.InvariantCulture);
Assert.Equal(0.4, result);
}
[Fact]
public void FirstCharConverter_NormalString_ReturnsFirstChar()
{
var converter = new FirstCharConverter();
var result = converter.Convert("Postman", typeof(string), null, CultureInfo.InvariantCulture);
Assert.Equal("P", result);
}
[Fact]
public void FirstCharConverter_EmptyString_ReturnsQuestionMark()
{
var converter = new FirstCharConverter();
var result = converter.Convert("", typeof(string), null, CultureInfo.InvariantCulture);
Assert.Equal("?", result);
}
[Fact]
public void FirstCharConverter_Null_ReturnsQuestionMark()
{
var converter = new FirstCharConverter();
var result = converter.Convert(null!, typeof(string), null, CultureInfo.InvariantCulture);
Assert.Equal("?", result);
}
}

View File

@@ -3,6 +3,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:PersonalToolBox">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Themes/DarkTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using System.IO;
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using PersonalToolBox.Services;
using PersonalToolBox.Views;
@@ -12,34 +13,81 @@ public partial class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
private static readonly string CrashLogPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"PersonalToolBox", "crash.log");
public App()
{
// 监听未处理的异常,写入文件日志
DispatcherUnhandledException += (s, e) =>
{
WriteCrashLog($"UI线程异常: {e.Exception}");
e.Handled = true;
MessageBox.Show($"发生未处理异常:\n{e.Exception.Message}\n\n详情已写入:\n{CrashLogPath}",
"错误", MessageBoxButton.OK, MessageBoxImage.Error);
};
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
WriteCrashLog($"未处理异常: {e.ExceptionObject}");
};
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
try
{
base.OnStartup(e);
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
var services = new ServiceCollection();
ConfigureServices(services);
Services = services.BuildServiceProvider();
// 启动时加载配置文件(含路径验证与容错)
var dataService = Services.GetRequiredService<IDataService>();
dataService.Load();
// 启动时加载配置文件(含路径验证与容错)
var dataService = Services.GetRequiredService<IDataService>();
dataService.Load();
var mainWindow = Services.GetRequiredService<MainWindow>();
mainWindow.Show();
var mainWindow = Services.GetRequiredService<MainWindow>();
mainWindow.Show();
}
catch (Exception ex)
{
WriteCrashLog($"启动失败: {ex}");
MessageBox.Show($"启动失败:\n{ex.Message}\n\n详情已写入:\n{CrashLogPath}",
"启动错误", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown();
}
}
/// <summary>
/// 注册所有服务到 DI 容器(单例模式)
/// 注册所有服务到 DI 容器
/// </summary>
private static void ConfigureServices(IServiceCollection services)
{
// 日志服务
services.AddSingleton<ILogService, LogService>();
// 数据持久化服务
services.AddSingleton<IDataService, JsonDataService>();
// 主窗口
services.AddSingleton<IProcessExecutionService, ProcessExecutionService>();
services.AddSingleton<ViewModels.MainViewModel>();
services.AddTransient<ViewModels.ToolEditViewModel>();
services.AddSingleton<MainWindow>();
}
/// <summary>
/// 将崩溃信息写入文件日志
/// </summary>
public static void WriteCrashLog(string message)
{
try
{
var dir = Path.GetDirectoryName(CrashLogPath);
if (dir != null) Directory.CreateDirectory(dir);
File.AppendAllText(CrashLogPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}{Environment.NewLine}");
}
catch
{
// 无法写入日志时静默忽略
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.ObjectModel;
using System.Windows;
namespace PersonalToolBox.Helpers;
/// <summary>
/// 主题切换辅助类,动态替换 Application 的 ResourceDictionary
/// </summary>
public static class ThemeHelper
{
private const string ThemePrefix = "Themes/";
private const string DarkThemePath = "Themes/DarkTheme.xaml";
private const string LightThemePath = "Themes/LightTheme.xaml";
/// <summary>
/// 切换主题Dark / Light
/// </summary>
public static void ApplyTheme(string theme)
{
var app = Application.Current;
if (app == null) return;
// 移除旧的主题资源字典
var oldDict = FindThemeDictionary(app.Resources.MergedDictionaries);
if (oldDict != null)
app.Resources.MergedDictionaries.Remove(oldDict);
// 加载新的主题资源字典
var path = theme switch
{
"Light" => LightThemePath,
_ => DarkThemePath
};
var newDict = new ResourceDictionary
{
Source = new Uri(path, UriKind.Relative)
};
app.Resources.MergedDictionaries.Add(newDict);
}
/// <summary>
/// 在当前合并字典中查找主题相关的 ResourceDictionary
/// </summary>
private static ResourceDictionary? FindThemeDictionary(Collection<ResourceDictionary> dictionaries)
{
foreach (var dict in dictionaries)
{
if (dict.Source != null && dict.Source.OriginalString.StartsWith(ThemePrefix))
return dict;
}
return null;
}
}

View File

@@ -0,0 +1,15 @@
using PersonalToolBox.Models;
namespace PersonalToolBox.Services;
/// <summary>
/// 进程执行服务接口,负责启动外部工具进程
/// </summary>
public interface IProcessExecutionService
{
/// <summary>
/// 执行指定的工具项
/// </summary>
/// <param name="tool">要执行的工具项</param>
void Execute(ToolItem tool);
}

View File

@@ -65,11 +65,22 @@ public class JsonDataService : IDataService
continue;
}
// 纯文件名(无路径分隔符)可能位于系统 PATH 中,不标记为失效
if (!tool.ExecutablePath.Contains('\\') && !tool.ExecutablePath.Contains('/'))
{
tool.IsValid = true;
continue;
}
if (!File.Exists(tool.ExecutablePath))
{
tool.IsValid = false;
_logService.Warning($"工具 \"{tool.Name}\" 路径失效,找不到文件: {tool.ExecutablePath}");
}
else
{
tool.IsValid = true;
}
}
_logService.Info($"配置加载完成: {Config.Categories.Count} 个分类, {Config.Tools.Count} 个工具");
@@ -79,6 +90,11 @@ public class JsonDataService : IDataService
_logService.Error($"配置文件 JSON 解析失败: {ex.Message}");
Config = new AppConfig();
}
catch (Exception ex)
{
_logService.Error($"配置文件加载失败: {ex.Message}");
Config = new AppConfig();
}
}
public void Save()

View File

@@ -0,0 +1,49 @@
using System.Diagnostics;
using PersonalToolBox.Models;
namespace PersonalToolBox.Services;
/// <summary>
/// 进程执行服务,负责启动外部工具进程并处理异常
/// </summary>
public class ProcessExecutionService : IProcessExecutionService
{
private readonly ILogService _logService;
public ProcessExecutionService(ILogService logService)
{
_logService = logService;
}
public void Execute(ToolItem tool)
{
if (tool == null)
{
_logService.Error("尝试执行空工具项");
return;
}
if (!tool.IsValid)
{
_logService.Warning($"无法运行工具 \"{tool.Name}\",路径失效: {tool.ExecutablePath}");
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = tool.ExecutablePath,
Arguments = tool.Arguments ?? string.Empty,
UseShellExecute = true
};
Process.Start(startInfo);
_logService.Info($"成功启动: {tool.Name}");
}
catch (Exception ex)
{
_logService.Error($"启动工具 \"{tool.Name}\" 失败: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,20 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 暗黑主题颜色定义 -->
<SolidColorBrush x:Key="Theme.Background" Color="#1E1E2E"/>
<SolidColorBrush x:Key="Theme.Foreground" Color="#CDD6F4"/>
<SolidColorBrush x:Key="Theme.SidebarBackground" Color="#181825"/>
<SolidColorBrush x:Key="Theme.CardBackground" Color="#313244"/>
<SolidColorBrush x:Key="Theme.CardBorder" Color="#45475A"/>
<SolidColorBrush x:Key="Theme.LogBackground" Color="#11111B"/>
<SolidColorBrush x:Key="Theme.Accent" Color="#89B4FA"/>
<SolidColorBrush x:Key="Theme.AccentHover" Color="#B4BEFE"/>
<SolidColorBrush x:Key="Theme.TextSecondary" Color="#A6ADC8"/>
<SolidColorBrush x:Key="Theme.InputBackground" Color="#313244"/>
<SolidColorBrush x:Key="Theme.InputBorder" Color="#585B70"/>
<SolidColorBrush x:Key="Theme.ButtonBackground" Color="#89B4FA"/>
<SolidColorBrush x:Key="Theme.ButtonForeground" Color="#1E1E2E"/>
<SolidColorBrush x:Key="Theme.ScrollBarBackground" Color="#313244"/>
</ResourceDictionary>

View File

@@ -0,0 +1,20 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 明亮主题颜色定义 -->
<SolidColorBrush x:Key="Theme.Background" Color="#EFF1F5"/>
<SolidColorBrush x:Key="Theme.Foreground" Color="#4C4F69"/>
<SolidColorBrush x:Key="Theme.SidebarBackground" Color="#E6E9EF"/>
<SolidColorBrush x:Key="Theme.CardBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Theme.CardBorder" Color="#CCD0DA"/>
<SolidColorBrush x:Key="Theme.LogBackground" Color="#DCE0E8"/>
<SolidColorBrush x:Key="Theme.Accent" Color="#1E66F5"/>
<SolidColorBrush x:Key="Theme.AccentHover" Color="#2A6EF5"/>
<SolidColorBrush x:Key="Theme.TextSecondary" Color="#6C6F85"/>
<SolidColorBrush x:Key="Theme.InputBackground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Theme.InputBorder" Color="#9CA0B0"/>
<SolidColorBrush x:Key="Theme.ButtonBackground" Color="#1E66F5"/>
<SolidColorBrush x:Key="Theme.ButtonForeground" Color="#FFFFFF"/>
<SolidColorBrush x:Key="Theme.ScrollBarBackground" Color="#CCD0DA"/>
</ResourceDictionary>

View File

@@ -0,0 +1,178 @@
using System.Collections.ObjectModel;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PersonalToolBox.Helpers;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
using PersonalToolBox.Views;
namespace PersonalToolBox.ViewModels;
/// <summary>
/// 主窗口 ViewModel管理 UI 状态、数据绑定和用户交互逻辑
/// </summary>
public partial class MainViewModel : ObservableObject
{
private readonly ILogService _logService;
private readonly IDataService _dataService;
private readonly IProcessExecutionService _processService;
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// 全部分类的虚拟对象
/// </summary>
private static readonly Category AllCategory = new() { Id = "", Name = "全部" };
public MainViewModel(
ILogService logService,
IDataService dataService,
IProcessExecutionService processService,
IServiceProvider serviceProvider)
{
_logService = logService;
_dataService = dataService;
_processService = processService;
_serviceProvider = serviceProvider;
LoadData();
}
// ───────────────────────────── 可观察属性 ─────────────────────────────
[ObservableProperty]
private string _searchText = string.Empty;
[ObservableProperty]
private Category? _selectedCategory;
public ObservableCollection<LogEntry> Logs => _logService.Logs;
public ObservableCollection<Category> Categories { get; } = new();
public ObservableCollection<ToolItem> Tools { get; } = new();
[ObservableProperty]
private ObservableCollection<ToolItem> _filteredTools = new();
[ObservableProperty]
private string _currentTheme = "Dark";
// ───────────────────────────── 命令 ─────────────────────────────
[RelayCommand]
private void ClearLogs() => _logService.Clear();
[RelayCommand]
private void ToggleTheme()
{
CurrentTheme = CurrentTheme == "Dark" ? "Light" : "Dark";
ThemeHelper.ApplyTheme(CurrentTheme);
_dataService.Config.Theme = CurrentTheme;
_dataService.Save();
_logService.Info($"已切换到{(CurrentTheme == "Dark" ? "" : "")}主题");
}
/// <summary>
/// 打开添加工具弹窗
/// </summary>
[RelayCommand]
private void AddTool()
{
var vm = _serviceProvider.GetRequiredService<ToolEditViewModel>();
var window = new ToolEditWindow(vm);
window.ShowDialog();
if (vm.Saved)
{
RefreshData();
}
}
/// <summary>
/// 打开编辑工具弹窗
/// </summary>
[RelayCommand]
private void EditTool(ToolItem tool)
{
if (tool == null) return;
// 创建编辑 ViewModel需要新建实例DI 容器无法区分参数)
var dataService = _serviceProvider.GetRequiredService<IDataService>();
var logService = _serviceProvider.GetRequiredService<ILogService>();
var editVm = new ToolEditViewModel(dataService, logService, tool);
var window = new ToolEditWindow(editVm);
window.ShowDialog();
if (editVm.Saved)
{
RefreshData();
}
}
/// <summary>
/// 执行工具(双击卡片或右键菜单)
/// </summary>
[RelayCommand]
private void ExecuteTool(ToolItem? tool)
{
if (tool == null) return;
_processService.Execute(tool);
}
// ───────────────────────────── 数据刷新 ─────────────────────────────
/// <summary>
/// 从配置重新加载数据并刷新 UI
/// </summary>
public void RefreshData()
{
var previousCategoryId = SelectedCategory?.Id;
Categories.Clear();
Categories.Add(AllCategory);
foreach (var cat in _dataService.Config.Categories)
Categories.Add(cat);
Tools.Clear();
foreach (var tool in _dataService.Config.Tools)
Tools.Add(tool);
// 恢复之前选中的分类(若仍存在),否则选中"全部"
SelectedCategory = Categories.FirstOrDefault(c => c.Id == previousCategoryId) ?? AllCategory;
ApplyFilter();
}
// ───────────────────────────── 初始化 ─────────────────────────────
private void LoadData()
{
RefreshData();
SelectedCategory = AllCategory;
CurrentTheme = _dataService.Config.Theme;
ThemeHelper.ApplyTheme(CurrentTheme);
}
// ───────────────────────────── 过滤逻辑 ─────────────────────────────
partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedCategoryChanged(Category? value) => ApplyFilter();
private void ApplyFilter()
{
var filtered = Tools.AsEnumerable();
if (SelectedCategory != null && !string.IsNullOrEmpty(SelectedCategory.Id))
filtered = filtered.Where(t => t.CategoryId == SelectedCategory.Id);
if (!string.IsNullOrWhiteSpace(SearchText))
filtered = filtered.Where(t =>
t.Name.Contains(SearchText, System.StringComparison.OrdinalIgnoreCase));
FilteredTools = new ObservableCollection<ToolItem>(filtered);
}
}

View File

@@ -0,0 +1,191 @@
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32;
using PersonalToolBox.Models;
using PersonalToolBox.Services;
namespace PersonalToolBox.ViewModels;
/// <summary>
/// 工具编辑窗口 ViewModel管理添加/编辑工具的交互逻辑
/// </summary>
public partial class ToolEditViewModel : ObservableObject
{
private readonly IDataService _dataService;
private readonly ILogService _logService;
private readonly ToolItem? _editingTool;
// ───────────────────────────── 可观察属性 ─────────────────────────────
[ObservableProperty]
private string _windowTitle = "添加工具";
[ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
private string _executablePath = string.Empty;
[ObservableProperty]
private string _arguments = string.Empty;
[ObservableProperty]
private string _hotKey = string.Empty;
[ObservableProperty]
private Category? _selectedCategory;
/// <summary>
/// 分类下拉列表
/// </summary>
public ObservableCollection<Category> Categories { get; } = new();
/// <summary>
/// 窗口关闭回调(由 View 层设置)
/// </summary>
public Action<bool?>? CloseAction { get; set; }
/// <summary>
/// 是否保存成功(供调用方判断是否需要刷新)
/// </summary>
public bool Saved { get; private set; }
// ───────────────────────────── 构造函数 ─────────────────────────────
public ToolEditViewModel(IDataService dataService, ILogService logService, ToolItem? toolToEdit = null)
{
_dataService = dataService;
_logService = logService;
_editingTool = toolToEdit;
// 加载分类列表
foreach (var cat in _dataService.Config.Categories)
Categories.Add(cat);
// 编辑模式
if (toolToEdit != null)
{
WindowTitle = "编辑工具";
Name = toolToEdit.Name;
ExecutablePath = toolToEdit.ExecutablePath;
Arguments = toolToEdit.Arguments;
HotKey = toolToEdit.HotKey;
SelectedCategory = Categories.FirstOrDefault(c => c.Id == toolToEdit.CategoryId);
}
}
// ───────────────────────────── 命令 ─────────────────────────────
/// <summary>
/// 浏览本地文件
/// </summary>
[RelayCommand]
private void BrowseFile()
{
var dialog = new OpenFileDialog
{
Title = "选择可执行文件或脚本",
Filter = "所有文件|*.*|可执行文件|*.exe|脚本文件|*.bat;*.cmd;*.ps1;*.py|快捷方式|*.lnk"
};
if (dialog.ShowDialog() == true)
{
ExecutablePath = dialog.FileName;
if (string.IsNullOrWhiteSpace(Name))
{
Name = Path.GetFileNameWithoutExtension(dialog.FileName);
}
}
}
/// <summary>
/// 保存工具
/// </summary>
[RelayCommand]
private void Save()
{
try
{
if (string.IsNullOrWhiteSpace(Name))
{
_logService.Warning("工具名称不能为空");
return;
}
if (string.IsNullOrWhiteSpace(ExecutablePath))
{
_logService.Warning($"工具 \"{Name}\" 路径不能为空");
return;
}
// 编辑模式:更新已有工具
if (_editingTool != null)
{
_editingTool.Name = Name.Trim();
_editingTool.ExecutablePath = ExecutablePath.Trim();
_editingTool.Arguments = Arguments.Trim();
_editingTool.HotKey = HotKey.Trim();
_editingTool.CategoryId = SelectedCategory?.Id ?? string.Empty;
_editingTool.IsValid = IsExecutablePathValid(ExecutablePath.Trim());
_logService.Info($"已更新工具: {Name.Trim()}");
}
// 添加模式:创建新工具
else
{
var newTool = new ToolItem
{
Name = Name.Trim(),
ExecutablePath = ExecutablePath.Trim(),
Arguments = Arguments.Trim(),
HotKey = HotKey.Trim(),
CategoryId = SelectedCategory?.Id ?? string.Empty,
IsValid = IsExecutablePathValid(ExecutablePath.Trim())
};
_dataService.Config.Tools.Add(newTool);
_logService.Info($"已添加工具: {Name.Trim()}");
}
_dataService.Save();
Saved = true;
CloseAction?.Invoke(true);
}
catch (Exception ex)
{
_logService.Error($"保存工具失败: {ex.Message}");
}
}
/// <summary>
/// 取消编辑
/// </summary>
[RelayCommand]
private void Cancel()
{
CloseAction?.Invoke(false);
}
/// <summary>
/// 验证可执行路径是否有效
/// </summary>
private static bool IsExecutablePathValid(string path)
{
if (string.IsNullOrWhiteSpace(path))
return false;
// URL 格式https://...)直接通过
if (Uri.TryCreate(path, UriKind.Absolute, out var uri) &&
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return true;
// 纯文件名(无路径分隔符)可能位于系统 PATH 中
if (!path.Contains('\\') && !path.Contains('/'))
return true;
return File.Exists(path);
}
}

View File

@@ -4,10 +4,273 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:PersonalToolBox.Views"
xmlns:vm="clr-namespace:PersonalToolBox.ViewModels"
xmlns:models="clr-namespace:PersonalToolBox.Models"
mc:Ignorable="d"
Title="个人工具箱" Height="600" Width="900"
WindowStartupLocation="CenterScreen">
<Grid>
Title="个人工具箱" Height="650" Width="960"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource Theme.Background}">
<Window.Resources>
<!-- 值转换器(必须定义在 DataTemplate 之前,确保 StaticResource 能解析) -->
<local:BoolToOpacityConverter x:Key="BoolToOpacityConverter"/>
<local:FirstCharConverter x:Key="FirstCharConverter"/>
<!-- 工具卡片模板 -->
<DataTemplate x:Key="ToolCardTemplate" DataType="{x:Type models:ToolItem}">
<Border Width="140" Height="100"
Margin="6"
Background="{DynamicResource Theme.CardBackground}"
BorderBrush="{DynamicResource Theme.CardBorder}"
BorderThickness="1"
CornerRadius="8"
Opacity="{Binding IsValid, Converter={StaticResource BoolToOpacityConverter}}"
Cursor="Hand"
ToolTip="{Binding Name}"
Tag="{Binding DataContext, RelativeSource={RelativeSource AncestorType=Window}}">
<Border.InputBindings>
<MouseBinding Gesture="LeftDoubleClick"
Command="{Binding DataContext.ExecuteToolCommand, RelativeSource={RelativeSource AncestorType=Window}}"
CommandParameter="{Binding}"/>
</Border.InputBindings>
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="运行"
Command="{Binding PlacementTarget.Tag.ExecuteToolCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}"/>
<MenuItem Header="编辑"
Command="{Binding PlacementTarget.Tag.EditToolCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBlock Text="{Binding Name, Converter={StaticResource FirstCharConverter}}"
FontSize="28"
Foreground="{DynamicResource Theme.Accent}"
HorizontalAlignment="Center"
Margin="0,0,0,6"/>
<TextBlock Text="{Binding Name}"
FontSize="12"
Foreground="{DynamicResource Theme.Foreground}"
HorizontalAlignment="Center"
TextTrimming="CharacterEllipsis"
MaxWidth="120"/>
</StackPanel>
</Border>
</DataTemplate>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="3"/>
<RowDefinition Height="120"/>
</Grid.RowDefinitions>
<!-- ──────────────── 内容区 ──────────────── -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- ─── 左侧边栏 ─── -->
<Border Grid.Column="0" Background="{DynamicResource Theme.SidebarBackground}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="60"/>
<RowDefinition Height="*"/>
<RowDefinition Height="45"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="个人工具箱"
FontSize="18" FontWeight="Bold"
Foreground="{DynamicResource Theme.Accent}"
VerticalAlignment="Center"
HorizontalAlignment="Center"/>
<ListBox Grid.Row="1"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource Theme.Foreground}"
SelectionMode="Single">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="16,10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Border"
Background="Transparent"
BorderBrush="Transparent"
BorderThickness="3,0,0,0"
Padding="{TemplateBinding Padding}">
<TextBlock Text="{Binding Name}"
Foreground="{DynamicResource Theme.Foreground}"
FontSize="14"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Border" Property="Background" Value="#20FFFFFF"/>
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource Theme.Accent}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Border" Property="Background" Value="#10FFFFFF"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<Button Grid.Row="2"
Command="{Binding ToggleThemeCommand}"
Background="Transparent"
BorderThickness="1"
BorderBrush="{DynamicResource Theme.CardBorder}"
Foreground="{DynamicResource Theme.Foreground}"
FontSize="12"
Height="35" Margin="10,0,10,10"
Cursor="Hand">
<StackPanel Orientation="Horizontal">
<TextBlock Text="◐" FontSize="16" Margin="0,0,8,0"
VerticalAlignment="Center"/>
<TextBlock Text="切换主题" VerticalAlignment="Center"/>
</StackPanel>
</Button>
</Grid>
</Border>
<!-- ─── 右侧主体区 ─── -->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="50"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0" Background="{DynamicResource Theme.Background}"
BorderBrush="{DynamicResource Theme.CardBorder}"
BorderThickness="0,0,0,1">
<Grid Margin="12,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Background="{DynamicResource Theme.InputBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.InputBorder}"
BorderThickness="1"
FontSize="14"
VerticalContentAlignment="Center"
Height="32"
Margin="0,0,12,0"
Padding="8,0"/>
<Button Grid.Column="1"
Content="+ 添加工具"
Command="{Binding AddToolCommand}"
Background="{DynamicResource Theme.ButtonBackground}"
Foreground="{DynamicResource Theme.ButtonForeground}"
BorderThickness="0"
FontSize="12"
Height="32"
Cursor="Hand">
<Button.Resources>
<Style TargetType="Border">
<Setter Property="CornerRadius" Value="4"/>
</Style>
</Button.Resources>
</Button>
</Grid>
</Border>
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
Background="{DynamicResource Theme.Background}">
<ItemsControl ItemsSource="{Binding FilteredTools}"
Margin="8">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<StaticResource ResourceKey="ToolCardTemplate"/>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
<!-- ──────────────── 分隔线 ──────────────── -->
<Border Grid.Row="1" Background="{DynamicResource Theme.CardBorder}"/>
<!-- ──────────────── 底部日志栏 ──────────────── -->
<Border Grid.Row="2" Background="{DynamicResource Theme.LogBackground}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="30"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Border Grid.Row="0"
Background="{DynamicResource Theme.SidebarBackground}"
BorderBrush="{DynamicResource Theme.CardBorder}"
BorderThickness="0,0,0,1">
<Grid Margin="10,0">
<TextBlock Text="运行日志"
Foreground="{DynamicResource Theme.Foreground}"
FontSize="12" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Content="清空日志"
Command="{Binding ClearLogsCommand}"
Background="Transparent"
Foreground="{DynamicResource Theme.TextSecondary}"
BorderThickness="0"
FontSize="11"
HorizontalAlignment="Right"
Height="22"
Cursor="Hand"/>
</Grid>
</Border>
<ListBox Grid.Row="1"
x:Name="LogListBox"
ItemsSource="{Binding Logs}"
Background="Transparent"
BorderThickness="0"
Foreground="{DynamicResource Theme.Foreground}"
FontFamily="Consolas"
FontSize="12"
VirtualizingPanel.IsVirtualizing="True">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0,1,0,0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<ContentPresenter/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:LogEntry}">
<TextBlock Text="{Binding .}" Padding="8,1"
Foreground="{DynamicResource Theme.Foreground}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -1,11 +1,63 @@
using System.Windows;
using System;
using System.Collections.Specialized;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
using PersonalToolBox.ViewModels;
namespace PersonalToolBox.Views;
public partial class MainWindow : Window
{
public MainWindow()
private readonly MainViewModel _viewModel;
public MainWindow(MainViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = viewModel;
viewModel.Logs.CollectionChanged += OnLogsCollectionChanged;
}
private void OnLogsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action == NotifyCollectionChangedAction.Add && LogListBox.Items.Count > 0)
{
Dispatcher.BeginInvoke(new Action(() =>
{
LogListBox.ScrollIntoView(LogListBox.Items[^1]);
}), System.Windows.Threading.DispatcherPriority.Background);
}
}
}
/// <summary>
/// bool → double: true=1.0, false=0.4 (工具卡片失效时置灰)
/// </summary>
public class BoolToOpacityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is bool b && b ? 1.0 : 0.4;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}
/// <summary>
/// string → string: 取字符串首字符作为图标占位
/// </summary>
public class FirstCharConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var s = value as string;
return string.IsNullOrEmpty(s) ? "?" : s[0].ToString();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}

View File

@@ -0,0 +1,126 @@
<Window x:Class="PersonalToolBox.Views.ToolEditWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:models="clr-namespace:PersonalToolBox.Models"
Title="{Binding WindowTitle}" Height="380" Width="480"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
Background="{DynamicResource Theme.Background}">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="30"/>
</Grid.ColumnDefinitions>
<!-- 工具名称 -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="名称:" Foreground="{DynamicResource Theme.Foreground}"
VerticalAlignment="Center" Margin="0,0,0,12"/>
<TextBox Grid.Row="0" Grid.Column="1" Grid.ColumnSpan="2"
Text="{Binding Name, UpdateSourceTrigger=PropertyChanged}"
Background="{DynamicResource Theme.InputBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.InputBorder}"
Height="30" Margin="0,0,0,12" Padding="6,0"/>
<!-- 工具路径 -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="路径:" Foreground="{DynamicResource Theme.Foreground}"
VerticalAlignment="Center" Margin="0,0,0,12"/>
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding ExecutablePath, UpdateSourceTrigger=PropertyChanged}"
Background="{DynamicResource Theme.InputBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.InputBorder}"
Height="30" Margin="0,0,0,12" Padding="6,0"/>
<Button Grid.Row="1" Grid.Column="2"
Content="..."
Command="{Binding BrowseFileCommand}"
Background="{DynamicResource Theme.ButtonBackground}"
Foreground="{DynamicResource Theme.ButtonForeground}"
BorderThickness="0"
Width="24" Height="24" Margin="6,3,0,12"
FontWeight="Bold" Cursor="Hand"/>
<!-- 运行参数 -->
<TextBlock Grid.Row="2" Grid.Column="0"
Text="参数:" Foreground="{DynamicResource Theme.Foreground}"
VerticalAlignment="Center" Margin="0,0,0,12"/>
<TextBox Grid.Row="2" Grid.Column="1" Grid.ColumnSpan="2"
Text="{Binding Arguments, UpdateSourceTrigger=PropertyChanged}"
Background="{DynamicResource Theme.InputBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.InputBorder}"
Height="30" Margin="0,0,0,12" Padding="6,0"/>
<!-- 所属分类 -->
<TextBlock Grid.Row="3" Grid.Column="0"
Text="分类:" Foreground="{DynamicResource Theme.Foreground}"
VerticalAlignment="Center" Margin="0,0,0,12"/>
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
DisplayMemberPath="Name"
Background="{DynamicResource Theme.InputBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.InputBorder}"
Height="30" Margin="0,0,0,12" Padding="4,0"/>
<!-- 全局快捷键 -->
<TextBlock Grid.Row="4" Grid.Column="0"
Text="快捷键:" Foreground="{DynamicResource Theme.Foreground}"
VerticalAlignment="Center" Margin="0,0,0,12"/>
<TextBox Grid.Row="4" Grid.Column="1" Grid.ColumnSpan="2"
Text="{Binding HotKey, UpdateSourceTrigger=PropertyChanged}"
Background="{DynamicResource Theme.InputBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.InputBorder}"
Height="30" Margin="0,0,0,12" Padding="6,0"/>
<!-- 提示文字 -->
<TextBlock Grid.Row="5" Grid.Column="1" Grid.ColumnSpan="2"
Text="快捷键格式示例: Ctrl+Alt+T"
Foreground="{DynamicResource Theme.TextSecondary}"
FontSize="11" Margin="0,0,0,16"/>
<!-- 分隔线 -->
<Border Grid.Row="6" Grid.ColumnSpan="3"
BorderBrush="{DynamicResource Theme.CardBorder}"
BorderThickness="0,1,0,0" Margin="0,0,0,14"/>
<!-- 按钮 -->
<StackPanel Grid.Row="7" Grid.Column="1" Grid.ColumnSpan="2"
Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="保存"
Command="{Binding SaveCommand}"
Background="{DynamicResource Theme.ButtonBackground}"
Foreground="{DynamicResource Theme.ButtonForeground}"
BorderThickness="0"
Width="80" Height="30"
FontSize="13"
Cursor="Hand"
Margin="0,0,10,0"/>
<Button Content="取消"
Command="{Binding CancelCommand}"
Background="{DynamicResource Theme.CardBackground}"
Foreground="{DynamicResource Theme.Foreground}"
BorderBrush="{DynamicResource Theme.CardBorder}"
BorderThickness="1"
Width="80" Height="30"
FontSize="13"
Cursor="Hand"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,21 @@
using System.Windows;
using PersonalToolBox.ViewModels;
namespace PersonalToolBox.Views;
public partial class ToolEditWindow : Window
{
public ToolEditWindow(ToolEditViewModel viewModel)
{
InitializeComponent();
viewModel.CloseAction = (result) =>
{
// DialogResult 会自动关闭由 ShowDialog() 显示的窗口,无需显式调用 Close()
DialogResult = result;
};
Owner = Application.Current.MainWindow;
DataContext = viewModel;
}
}