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:
178
PersonalToolBox/ViewModels/MainViewModel.cs
Normal file
178
PersonalToolBox/ViewModels/MainViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
191
PersonalToolBox/ViewModels/ToolEditViewModel.cs
Normal file
191
PersonalToolBox/ViewModels/ToolEditViewModel.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user