feat: 统一项目命名并补充路径失效报告

将内部项目目录、命名空间、配置目录、自启注册表值和设计/开发文档统一为 PersonalToolbox。

扩展路径校验服务,输出失效工具、字段、原因和路径,并在启动日志、设置页路径检查与导入配置流程中展示明细报告。

验证:dotnet build PersonalToolbox.sln
This commit is contained in:
2026-05-27 14:20:19 +08:00
parent dfc306818a
commit 26a22eef1c
32 changed files with 236 additions and 150 deletions

View File

@@ -0,0 +1,55 @@
using PersonalToolbox.Models;
namespace PersonalToolbox.ViewModels;
public sealed class CombinationMemberViewModel : ObservableObject
{
private readonly Func<string, ToolItem?> _toolResolver;
private bool _enabled;
private int _intervalAfterMs;
public CombinationMemberViewModel(CombinationMember member, Func<string, ToolItem?> toolResolver)
{
Member = member;
_toolResolver = toolResolver;
_enabled = member.Enabled;
_intervalAfterMs = member.IntervalAfterMs;
}
public CombinationMember Member { get; }
public string ToolId => Member.ToolId;
public string ToolName => _toolResolver(Member.ToolId)?.Name ?? "引用不存在";
public string ToolTypeLabel => _toolResolver(Member.ToolId)?.Type switch
{
ToolType.System => "系统",
ToolType.Local => "本地",
ToolType.Url => "网址",
ToolType.Combination => "组合",
_ => "未知"
};
public bool Enabled
{
get => _enabled;
set
{
if (SetProperty(ref _enabled, value))
{
Member.Enabled = value;
}
}
}
public int IntervalAfterMs
{
get => _intervalAfterMs;
set
{
var normalized = Math.Max(0, value);
if (SetProperty(ref _intervalAfterMs, normalized))
{
Member.IntervalAfterMs = normalized;
}
}
}
}

View File

@@ -0,0 +1,568 @@
using System.Collections.ObjectModel;
using System.Windows;
using PersonalToolbox.Commands;
using PersonalToolbox.Models;
using PersonalToolbox.Services;
using PersonalToolbox.Views;
using Clipboard = System.Windows.Clipboard;
using MessageBox = System.Windows.MessageBox;
namespace PersonalToolbox.ViewModels;
public sealed class MainWindowViewModel : ObservableObject
{
private readonly ConfigurationService _configurationService;
private readonly ToolLaunchService _toolLaunchService;
private readonly AutoRunService _autoRunService;
private readonly PathValidationService _pathValidationService;
private readonly StartupService _startupService;
private ToolboxData _data = new();
private CategoryItem? _selectedCategory;
private ToolCardViewModel? _selectedTool;
private string _searchText = "";
private bool _isLogPanelExpanded = true;
public MainWindowViewModel()
{
_configurationService = new ConfigurationService();
_toolLaunchService = new ToolLaunchService();
_autoRunService = new AutoRunService(_toolLaunchService);
_pathValidationService = new PathValidationService();
_startupService = new StartupService();
AddLocalToolCommand = new RelayCommand(AddLocalTool);
AddUrlToolCommand = new RelayCommand(AddUrlTool);
AddCombinationCommand = new RelayCommand(AddCombination);
LaunchSelectedCommand = new AsyncRelayCommand(LaunchSelectedAsync, () => SelectedTool is not null);
EditSelectedCommand = new RelayCommand(EditSelectedTool, () => SelectedTool is not null);
DeleteSelectedCommand = new RelayCommand(DeleteSelectedTool, () => SelectedTool is not null);
OpenSettingsCommand = new RelayCommand(OpenSettings);
AddCategoryCommand = new RelayCommand(AddCategory);
RenameCategoryCommand = new RelayCommand(RenameCategory, () => SelectedCategory is not null);
DeleteCategoryCommand = new RelayCommand(DeleteCategory, () => SelectedCategory is not null && SelectedCategory.Id != SystemToolService.UncategorizedCategoryId);
ClearLogsCommand = new RelayCommand(() => Logs.Clear());
CopyLogsCommand = new RelayCommand(CopyLogs, () => Logs.Count > 0);
ToggleLogPanelCommand = new RelayCommand(() => IsLogPanelExpanded = !IsLogPanelExpanded);
}
public event EventHandler? HotkeysRefreshRequested;
public Window? Owner { get; set; }
public ObservableCollection<CategoryItem> Categories { get; } = [];
public ObservableCollection<ToolCardViewModel> Tools { get; } = [];
public ObservableCollection<LogMessage> Logs { get; } = [];
public RelayCommand AddLocalToolCommand { get; }
public RelayCommand AddUrlToolCommand { get; }
public RelayCommand AddCombinationCommand { get; }
public AsyncRelayCommand LaunchSelectedCommand { get; }
public RelayCommand EditSelectedCommand { get; }
public RelayCommand DeleteSelectedCommand { get; }
public RelayCommand OpenSettingsCommand { get; }
public RelayCommand AddCategoryCommand { get; }
public RelayCommand RenameCategoryCommand { get; }
public RelayCommand DeleteCategoryCommand { get; }
public RelayCommand ClearLogsCommand { get; }
public RelayCommand CopyLogsCommand { get; }
public RelayCommand ToggleLogPanelCommand { get; }
public ToolboxData Data => _data;
public ConfigurationService ConfigurationService => _configurationService;
public StartupService StartupService => _startupService;
public CategoryItem? SelectedCategory
{
get => _selectedCategory;
set
{
if (SetProperty(ref _selectedCategory, value))
{
RefreshTools();
RaiseSelectionCommandState();
}
}
}
public ToolCardViewModel? SelectedTool
{
get => _selectedTool;
set
{
if (SetProperty(ref _selectedTool, value))
{
RaiseSelectionCommandState();
}
}
}
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value))
{
RefreshTools();
}
}
}
public bool IsLogPanelExpanded
{
get => _isLogPanelExpanded;
set
{
if (SetProperty(ref _isLogPanelExpanded, value))
{
_data.Settings.LogPanelExpanded = value;
_ = SaveAsync();
}
}
}
public async Task InitializeAsync(Window owner)
{
Owner = owner;
_data = await _configurationService.LoadAsync();
_isLogPanelExpanded = _data.Settings.LogPanelExpanded;
OnPropertyChanged(nameof(IsLogPanelExpanded));
var pathReport = _pathValidationService.ValidateTools(_data.Tools);
RefreshCategories();
RefreshTools();
AddLog(LogLevel.Info, $"配置目录:{_configurationService.ConfigDirectory}");
AddLog(LogLevel.Success, "个人工具箱已启动。");
if (pathReport.HasIssues)
{
LogPathValidationReport(pathReport);
}
await SaveAsync();
}
public async Task RunAutoRunAsync(CancellationToken cancellationToken = default)
{
_configurationService.MergeAutoRunEntries(_data);
await SaveAsync(cancellationToken);
await _autoRunService.ExecuteAsync(_data, AddLog, cancellationToken);
}
public async Task LaunchToolByIdAsync(string toolId)
{
var tool = _data.Tools.FirstOrDefault(item => item.Id == toolId && !item.IsDeleted);
if (tool is null)
{
AddLog(LogLevel.Warning, $"快捷键目标不存在:{toolId}");
return;
}
await _toolLaunchService.LaunchAsync(tool, _data.Tools, AddLog);
}
public async Task SaveAsync(CancellationToken cancellationToken = default)
{
_configurationService.MergeAutoRunEntries(_data);
await _configurationService.SaveAsync(_data, cancellationToken);
}
public void RefreshHotkeys()
{
HotkeysRefreshRequested?.Invoke(this, EventArgs.Empty);
RefreshTools();
}
public void SetGlobalHotkeysEnabled(bool enabled)
{
_data.Settings.GlobalHotkeysEnabled = enabled;
AddLog(LogLevel.Info, enabled ? "全局快捷键已开启。" : "全局快捷键已关闭。");
_ = SaveAsync();
RefreshHotkeys();
}
public void AddLog(LogLevel level, string message)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
Logs.Add(new LogMessage
{
Level = level,
Message = message
});
while (Logs.Count > 500)
{
Logs.RemoveAt(0);
}
CopyLogsCommand.RaiseCanExecuteChanged();
});
}
private void AddLog(LogMessage message)
{
AddLog(message.Level, message.Message);
}
private async Task LaunchSelectedAsync()
{
if (SelectedTool is null)
{
return;
}
await _toolLaunchService.LaunchAsync(SelectedTool.Tool, _data.Tools, AddLog);
RefreshTools();
}
private void AddLocalTool()
{
var tool = CreateBaseTool(ToolType.Local);
tool.Name = "新的本地工具";
tool.IconKey = "folder";
var edited = ToolEditorWindow.Edit(tool, Categories, Owner);
if (edited is null)
{
return;
}
edited.SortOrder = NextToolSortOrder();
_data.Tools.Add(edited);
_ = _pathValidationService.ValidateTools(_data.Tools);
RefreshTools();
AddLog(LogLevel.Success, $"已添加本地工具:{edited.Name}");
_ = SaveAsync();
RefreshHotkeys();
}
private void AddUrlTool()
{
var tool = CreateBaseTool(ToolType.Url);
tool.Name = "新的网址";
tool.IconKey = "link";
var edited = ToolEditorWindow.Edit(tool, Categories, Owner);
if (edited is null)
{
return;
}
edited.SortOrder = NextToolSortOrder();
_data.Tools.Add(edited);
RefreshTools();
AddLog(LogLevel.Success, $"已添加网址工具:{edited.Name}");
_ = SaveAsync();
RefreshHotkeys();
}
private void AddCombination()
{
var tool = CreateBaseTool(ToolType.Combination);
tool.Name = "新的组合";
tool.IconKey = "combination";
tool.Combination = new CombinationConfig();
var edited = CombinationEditorWindow.Edit(tool, Categories, _data.Tools, _toolLaunchService, Owner);
if (edited is null)
{
return;
}
edited.SortOrder = NextToolSortOrder();
_data.Tools.Add(edited);
RefreshTools();
AddLog(LogLevel.Success, $"已添加组合:{edited.Name}");
_ = SaveAsync();
RefreshHotkeys();
}
private void EditSelectedTool()
{
if (SelectedTool is null)
{
return;
}
var original = SelectedTool.Tool;
var candidate = original.Clone();
ToolItem? edited = original.Type == ToolType.Combination
? CombinationEditorWindow.Edit(candidate, Categories, _data.Tools, _toolLaunchService, Owner)
: ToolEditorWindow.Edit(candidate, Categories, Owner);
if (edited is null)
{
return;
}
CopyToolValues(edited, original);
_ = _pathValidationService.ValidateTools(_data.Tools);
RefreshTools();
AddLog(LogLevel.Success, $"已保存工具:{original.Name}");
_ = SaveAsync();
RefreshHotkeys();
}
private void DeleteSelectedTool()
{
if (SelectedTool is null)
{
return;
}
var tool = SelectedTool.Tool;
var confirmed = MessageBox.Show(
Owner,
$"确定删除“{tool.Name}”吗?组合中对它的引用也会失效。",
"删除工具",
MessageBoxButton.YesNo,
MessageBoxImage.Warning) == MessageBoxResult.Yes;
if (!confirmed)
{
return;
}
tool.IsDeleted = true;
foreach (var combination in _data.Tools.Where(item => item.Type == ToolType.Combination && item.Combination is not null))
{
combination.Combination!.Members.RemoveAll(member => member.ToolId == tool.Id);
}
RefreshTools();
AddLog(LogLevel.Warning, $"已删除工具:{tool.Name}");
_ = SaveAsync();
RefreshHotkeys();
}
private void OpenSettings()
{
var window = new SettingsWindow(_data, _configurationService, _startupService, _pathValidationService, _toolLaunchService)
{
Owner = Owner
};
if (window.ShowDialog() == true)
{
_data = window.Data;
_ = _pathValidationService.ValidateTools(_data.Tools);
RefreshCategories();
RefreshTools();
AddLog(LogLevel.Success, "设置已保存。");
_ = SaveAsync();
RefreshHotkeys();
}
}
private void AddCategory()
{
var name = PromptWindow.Prompt("新建分类", "分类名称", "", Owner);
if (string.IsNullOrWhiteSpace(name))
{
return;
}
_data.Categories.Add(new CategoryItem
{
Name = name.Trim(),
IconKey = "category",
SortOrder = _data.Categories.Count == 0 ? 0 : _data.Categories.Max(category => category.SortOrder) + 1
});
RefreshCategories();
AddLog(LogLevel.Success, $"已新建分类:{name.Trim()}");
_ = SaveAsync();
}
private void RenameCategory()
{
if (SelectedCategory is null)
{
return;
}
var name = PromptWindow.Prompt("重命名分类", "分类名称", SelectedCategory.Name, Owner);
if (string.IsNullOrWhiteSpace(name))
{
return;
}
SelectedCategory.Name = name.Trim();
RefreshCategories();
RefreshTools();
AddLog(LogLevel.Success, $"已重命名分类:{name.Trim()}");
_ = SaveAsync();
}
private void DeleteCategory()
{
if (SelectedCategory is null)
{
return;
}
if (SelectedCategory.Id == SystemToolService.UncategorizedCategoryId)
{
AddLog(LogLevel.Warning, "未分类为固定分类,不能删除。");
return;
}
var confirmed = MessageBox.Show(
Owner,
$"删除分类“{SelectedCategory.Name}”后,其中工具会移动到“未分类”。是否继续?",
"删除分类",
MessageBoxButton.YesNo,
MessageBoxImage.Warning) == MessageBoxResult.Yes;
if (!confirmed)
{
return;
}
foreach (var tool in _data.Tools.Where(tool => tool.CategoryId == SelectedCategory.Id))
{
tool.CategoryId = SystemToolService.UncategorizedCategoryId;
}
var deletedName = SelectedCategory.Name;
_data.Categories.Remove(SelectedCategory);
RefreshCategories();
SelectedCategory = _data.Categories.FirstOrDefault(category => category.Id == SystemToolService.UncategorizedCategoryId);
AddLog(LogLevel.Warning, $"已删除分类:{deletedName}");
_ = SaveAsync();
}
private void CopyLogs()
{
if (Logs.Count == 0)
{
return;
}
Clipboard.SetText(string.Join(Environment.NewLine, Logs.Select(log => log.DisplayText)));
AddLog(LogLevel.Success, "已复制底部信息。");
}
private void LogPathValidationReport(PathValidationReport report)
{
AddLog(LogLevel.Warning, $"路径检查完成:发现 {report.InvalidToolCount} 个失效工具,{report.Issues.Count} 个问题。");
foreach (var issue in report.Issues.Take(8))
{
var pathText = string.IsNullOrWhiteSpace(issue.Path) ? "" : $"{issue.Path}";
AddLog(LogLevel.Warning, $"路径失效:{issue.ToolName}{issue.FieldName} {issue.Reason}{pathText}");
}
if (report.Issues.Count > 8)
{
AddLog(LogLevel.Warning, $"还有 {report.Issues.Count - 8} 个路径问题未显示,可在设置中查看完整报告。");
}
}
private ToolItem CreateBaseTool(ToolType type)
{
return new ToolItem
{
Id = Guid.NewGuid().ToString("N"),
Type = type,
CategoryId = SelectedCategory?.Id ?? Categories.FirstOrDefault()?.Id ?? SystemToolService.UncategorizedCategoryId,
SortOrder = NextToolSortOrder()
};
}
private int NextToolSortOrder()
{
return _data.Tools.Count == 0 ? 0 : _data.Tools.Max(tool => tool.SortOrder) + 1;
}
private void RefreshCategories()
{
Categories.Clear();
foreach (var category in _data.Categories.OrderBy(category => category.SortOrder).ThenBy(category => category.Name))
{
Categories.Add(category);
}
SelectedCategory ??= Categories.FirstOrDefault();
if (SelectedCategory is not null && Categories.All(category => category.Id != SelectedCategory.Id))
{
SelectedCategory = Categories.FirstOrDefault();
}
DeleteCategoryCommand.RaiseCanExecuteChanged();
RenameCategoryCommand.RaiseCanExecuteChanged();
}
private void RefreshTools()
{
var selectedId = SelectedTool?.Id;
Tools.Clear();
var query = _data.Tools.Where(tool => !tool.IsDeleted);
if (!string.IsNullOrWhiteSpace(SearchText))
{
var keyword = SearchText.Trim();
query = query.Where(tool => MatchesSearch(tool, keyword));
}
else if (SelectedCategory is not null)
{
query = query.Where(tool => tool.CategoryId == SelectedCategory.Id);
}
foreach (var tool in query.OrderBy(tool => tool.SortOrder).ThenBy(tool => tool.Name))
{
Tools.Add(new ToolCardViewModel(tool, ResolveCategoryName));
}
SelectedTool = Tools.FirstOrDefault(tool => tool.Id == selectedId);
RaiseSelectionCommandState();
}
private bool MatchesSearch(ToolItem tool, string keyword)
{
return Contains(tool.Name, keyword)
|| Contains(tool.Description, keyword)
|| Contains(tool.LaunchTarget, keyword)
|| Contains(tool.Url, keyword)
|| Contains(ResolveCategoryName(tool.CategoryId), keyword);
}
private static bool Contains(string? source, string keyword)
{
return !string.IsNullOrWhiteSpace(source)
&& source.Contains(keyword, StringComparison.CurrentCultureIgnoreCase);
}
private string ResolveCategoryName(string categoryId)
{
return _data.Categories.FirstOrDefault(category => category.Id == categoryId)?.Name ?? "未分类";
}
private void RaiseSelectionCommandState()
{
LaunchSelectedCommand.RaiseCanExecuteChanged();
EditSelectedCommand.RaiseCanExecuteChanged();
DeleteSelectedCommand.RaiseCanExecuteChanged();
RenameCategoryCommand.RaiseCanExecuteChanged();
DeleteCategoryCommand.RaiseCanExecuteChanged();
}
private static void CopyToolValues(ToolItem source, ToolItem target)
{
target.Name = source.Name;
target.Description = source.Description;
target.Type = source.Type;
target.CategoryId = source.CategoryId;
target.IconKey = source.IconKey;
target.LaunchTarget = source.LaunchTarget;
target.Url = source.Url;
target.Arguments = source.Arguments;
target.WorkingDirectory = source.WorkingDirectory;
target.SystemToolKey = source.SystemToolKey;
target.Hotkey = source.Hotkey;
target.AutoRunEnabled = source.AutoRunEnabled;
target.RunAsAdmin = source.RunAsAdmin;
target.Combination = source.Combination?.Clone();
}
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace PersonalToolbox.ViewModels;
public abstract class ObservableObject : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(storage, value))
{
return false;
}
storage = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,124 @@
using PersonalToolbox.Models;
namespace PersonalToolbox.ViewModels;
public sealed class ToolCardViewModel : ObservableObject
{
private readonly Func<string, string> _categoryNameResolver;
public ToolCardViewModel(ToolItem tool, Func<string, string> categoryNameResolver)
{
Tool = tool;
_categoryNameResolver = categoryNameResolver;
}
public ToolItem Tool { get; }
public string Id => Tool.Id;
public string Name => Tool.Name;
public string Description => string.IsNullOrWhiteSpace(Tool.Description) ? "暂无说明" : Tool.Description;
public string CategoryName => _categoryNameResolver(Tool.CategoryId);
public string IconText => IconKeyToText(Tool.IconKey, Tool.Type);
public string TypeLabel => Tool.Type switch
{
ToolType.System => "系统",
ToolType.Local => "本地",
ToolType.Url => "网址",
ToolType.Combination => "组合",
_ => "工具"
};
public IReadOnlyList<string> StatusBadges
{
get
{
var badges = new List<string>();
if (Tool.PathInvalid)
{
badges.Add("路径失效");
}
if (!string.IsNullOrWhiteSpace(Tool.Hotkey))
{
badges.Add("快捷键");
}
if (Tool.AutoRunEnabled)
{
badges.Add("自动运行");
}
if (Tool.RunAsAdmin)
{
badges.Add("管理员");
}
return badges;
}
}
public string DetailText
{
get
{
return Tool.Type switch
{
ToolType.Local => Tool.LaunchTarget ?? "",
ToolType.Url => Tool.Url ?? "",
ToolType.System => Tool.LaunchTarget ?? "",
ToolType.Combination => $"{Tool.Combination?.Members.Count ?? 0} 个成员",
_ => ""
};
}
}
public void Refresh()
{
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(Description));
OnPropertyChanged(nameof(CategoryName));
OnPropertyChanged(nameof(IconText));
OnPropertyChanged(nameof(TypeLabel));
OnPropertyChanged(nameof(StatusBadges));
OnPropertyChanged(nameof(DetailText));
}
private static string IconKeyToText(string iconKey, ToolType type)
{
if (!string.IsNullOrWhiteSpace(iconKey))
{
var mapped = iconKey.ToLowerInvariant() switch
{
"notepad" => "TXT",
"calculator" => "123",
"taskmgr" => "CPU",
"control" => "CTL",
"settings" => "SET",
"device" => "DEV",
"disk" => "DSK",
"service" => "SVC",
"registry" => "REG",
"network" => "NET",
"apps" => "APP",
"link" => "URL",
"folder" => "DIR",
"combination" => "COM",
_ => ""
};
if (!string.IsNullOrWhiteSpace(mapped))
{
return mapped;
}
}
return type switch
{
ToolType.System => "SYS",
ToolType.Local => "LOC",
ToolType.Url => "URL",
ToolType.Combination => "COM",
_ => "BOX"
};
}
}