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,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);
}
}