feat: 补齐工具箱剩余 MVP 能力

实现轻量图标系统、图标选择器、本地图标导入和关联图标缓存。

补齐外观设置、捕获式快捷键录入、卡片右键菜单、分类图标编辑,以及分类和卡片拖拽排序。

同时将配置数据版本升级到 2,并在导入和加载时拒绝更高版本配置,避免误读未来格式。
This commit is contained in:
2026-05-27 14:58:41 +08:00
parent 26a22eef1c
commit 0047be65ca
23 changed files with 1735 additions and 100 deletions

View File

@@ -16,6 +16,7 @@ public sealed class MainWindowViewModel : ObservableObject
private readonly AutoRunService _autoRunService;
private readonly PathValidationService _pathValidationService;
private readonly StartupService _startupService;
private readonly IconService _iconService;
private ToolboxData _data = new();
private CategoryItem? _selectedCategory;
private ToolCardViewModel? _selectedTool;
@@ -29,16 +30,23 @@ public sealed class MainWindowViewModel : ObservableObject
_autoRunService = new AutoRunService(_toolLaunchService);
_pathValidationService = new PathValidationService();
_startupService = new StartupService();
_iconService = new IconService(_configurationService.IconsDirectory);
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);
RenameSelectedCommand = new RelayCommand(RenameSelectedTool, () => SelectedTool is not null);
DuplicateSelectedCommand = new RelayCommand(DuplicateSelectedTool, () => SelectedTool is not null);
ToggleSelectedAutoRunCommand = new RelayCommand(ToggleSelectedAutoRun, () => SelectedTool is not null);
MoveSelectedToCategoryCommand = new RelayCommand(MoveSelectedToCategory, () => SelectedTool is not null);
FixSelectedPathCommand = new RelayCommand(EditSelectedTool, () => SelectedTool?.CanFixPath == true);
DeleteSelectedCommand = new RelayCommand(DeleteSelectedTool, () => SelectedTool is not null);
OpenSettingsCommand = new RelayCommand(OpenSettings);
AddCategoryCommand = new RelayCommand(AddCategory);
RenameCategoryCommand = new RelayCommand(RenameCategory, () => SelectedCategory is not null);
EditCategoryIconCommand = new RelayCommand(EditCategoryIcon, () => 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);
@@ -46,6 +54,7 @@ public sealed class MainWindowViewModel : ObservableObject
}
public event EventHandler? HotkeysRefreshRequested;
public event EventHandler? SettingsChanged;
public Window? Owner { get; set; }
@@ -58,10 +67,16 @@ public sealed class MainWindowViewModel : ObservableObject
public RelayCommand AddCombinationCommand { get; }
public AsyncRelayCommand LaunchSelectedCommand { get; }
public RelayCommand EditSelectedCommand { get; }
public RelayCommand RenameSelectedCommand { get; }
public RelayCommand DuplicateSelectedCommand { get; }
public RelayCommand ToggleSelectedAutoRunCommand { get; }
public RelayCommand MoveSelectedToCategoryCommand { get; }
public RelayCommand FixSelectedPathCommand { get; }
public RelayCommand DeleteSelectedCommand { get; }
public RelayCommand OpenSettingsCommand { get; }
public RelayCommand AddCategoryCommand { get; }
public RelayCommand RenameCategoryCommand { get; }
public RelayCommand EditCategoryIconCommand { get; }
public RelayCommand DeleteCategoryCommand { get; }
public RelayCommand ClearLogsCommand { get; }
public RelayCommand CopyLogsCommand { get; }
@@ -126,6 +141,12 @@ public sealed class MainWindowViewModel : ObservableObject
Owner = owner;
_data = await _configurationService.LoadAsync();
_isLogPanelExpanded = _data.Settings.LogPanelExpanded;
_iconService.EnsureDirectories();
foreach (var tool in _data.Tools.Where(tool => !tool.IsDeleted))
{
_iconService.EnsureToolIcon(tool);
}
OnPropertyChanged(nameof(IsLogPanelExpanded));
var pathReport = _pathValidationService.ValidateTools(_data.Tools);
@@ -222,12 +243,13 @@ public sealed class MainWindowViewModel : ObservableObject
tool.Name = "新的本地工具";
tool.IconKey = "folder";
var edited = ToolEditorWindow.Edit(tool, Categories, Owner);
var edited = ToolEditorWindow.Edit(tool, Categories, _iconService, Owner);
if (edited is null)
{
return;
}
_iconService.EnsureToolIcon(edited);
edited.SortOrder = NextToolSortOrder();
_data.Tools.Add(edited);
_ = _pathValidationService.ValidateTools(_data.Tools);
@@ -243,12 +265,13 @@ public sealed class MainWindowViewModel : ObservableObject
tool.Name = "新的网址";
tool.IconKey = "link";
var edited = ToolEditorWindow.Edit(tool, Categories, Owner);
var edited = ToolEditorWindow.Edit(tool, Categories, _iconService, Owner);
if (edited is null)
{
return;
}
_iconService.EnsureToolIcon(edited);
edited.SortOrder = NextToolSortOrder();
_data.Tools.Add(edited);
RefreshTools();
@@ -264,12 +287,13 @@ public sealed class MainWindowViewModel : ObservableObject
tool.IconKey = "combination";
tool.Combination = new CombinationConfig();
var edited = CombinationEditorWindow.Edit(tool, Categories, _data.Tools, _toolLaunchService, Owner);
var edited = CombinationEditorWindow.Edit(tool, Categories, _data.Tools, _toolLaunchService, _iconService, Owner);
if (edited is null)
{
return;
}
_iconService.EnsureToolIcon(edited);
edited.SortOrder = NextToolSortOrder();
_data.Tools.Add(edited);
RefreshTools();
@@ -288,14 +312,15 @@ public sealed class MainWindowViewModel : ObservableObject
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);
? CombinationEditorWindow.Edit(candidate, Categories, _data.Tools, _toolLaunchService, _iconService, Owner)
: ToolEditorWindow.Edit(candidate, Categories, _iconService, Owner);
if (edited is null)
{
return;
}
_iconService.EnsureToolIcon(edited);
CopyToolValues(edited, original);
_ = _pathValidationService.ValidateTools(_data.Tools);
RefreshTools();
@@ -304,6 +329,83 @@ public sealed class MainWindowViewModel : ObservableObject
RefreshHotkeys();
}
private void RenameSelectedTool()
{
if (SelectedTool is null)
{
return;
}
var tool = SelectedTool.Tool;
var name = PromptWindow.Prompt("重命名工具", "工具名称", tool.Name, Owner);
if (string.IsNullOrWhiteSpace(name))
{
return;
}
tool.Name = name.Trim();
RefreshTools();
AddLog(LogLevel.Success, $"已重命名工具:{tool.Name}");
_ = SaveAsync();
}
private void DuplicateSelectedTool()
{
if (SelectedTool is null)
{
return;
}
var copy = SelectedTool.Tool.Clone();
copy.Id = Guid.NewGuid().ToString("N");
copy.Name = $"{copy.Name} 副本";
copy.SortOrder = NextToolSortOrder();
copy.Hotkey = null;
copy.HotkeyStatus = "未设置";
copy.IsDeleted = false;
if (copy.Type == ToolType.System)
{
copy.SystemToolKey = null;
}
_data.Tools.Add(copy);
RefreshTools();
SelectedTool = Tools.FirstOrDefault(tool => tool.Id == copy.Id);
AddLog(LogLevel.Success, $"已复制工具:{copy.Name}");
_ = SaveAsync();
}
private void ToggleSelectedAutoRun()
{
if (SelectedTool is null)
{
return;
}
var tool = SelectedTool.Tool;
tool.AutoRunEnabled = !tool.AutoRunEnabled;
_configurationService.MergeAutoRunEntries(_data);
RefreshTools();
AddLog(LogLevel.Success, tool.AutoRunEnabled ? $"已加入启动时自动运行:{tool.Name}" : $"已取消启动时自动运行:{tool.Name}");
_ = SaveAsync();
}
private void MoveSelectedToCategory()
{
if (SelectedTool is null)
{
return;
}
var category = CategoryPickerWindow.Select(Categories, SelectedTool.Tool.CategoryId, Owner);
if (category is null)
{
return;
}
MoveToolToCategory(SelectedTool.Id, category.Id);
}
private void DeleteSelectedTool()
{
if (SelectedTool is null)
@@ -351,6 +453,7 @@ public sealed class MainWindowViewModel : ObservableObject
RefreshTools();
AddLog(LogLevel.Success, "设置已保存。");
_ = SaveAsync();
SettingsChanged?.Invoke(this, EventArgs.Empty);
RefreshHotkeys();
}
}
@@ -395,6 +498,25 @@ public sealed class MainWindowViewModel : ObservableObject
_ = SaveAsync();
}
private void EditCategoryIcon()
{
if (SelectedCategory is null)
{
return;
}
var iconKey = IconPickerWindow.SelectIcon(SelectedCategory.IconKey, _iconService, Owner);
if (string.IsNullOrWhiteSpace(iconKey))
{
return;
}
SelectedCategory.IconKey = iconKey;
RefreshCategories();
AddLog(LogLevel.Success, $"已更新分类图标:{SelectedCategory.Name}");
_ = SaveAsync();
}
private void DeleteCategory()
{
if (SelectedCategory is null)
@@ -492,6 +614,7 @@ public sealed class MainWindowViewModel : ObservableObject
DeleteCategoryCommand.RaiseCanExecuteChanged();
RenameCategoryCommand.RaiseCanExecuteChanged();
EditCategoryIconCommand.RaiseCanExecuteChanged();
}
private void RefreshTools()
@@ -512,7 +635,7 @@ public sealed class MainWindowViewModel : ObservableObject
foreach (var tool in query.OrderBy(tool => tool.SortOrder).ThenBy(tool => tool.Name))
{
Tools.Add(new ToolCardViewModel(tool, ResolveCategoryName));
Tools.Add(new ToolCardViewModel(tool, ResolveCategoryName, _iconService.ResolveImagePath, _data.Settings));
}
SelectedTool = Tools.FirstOrDefault(tool => tool.Id == selectedId);
@@ -539,15 +662,87 @@ public sealed class MainWindowViewModel : ObservableObject
return _data.Categories.FirstOrDefault(category => category.Id == categoryId)?.Name ?? "未分类";
}
public void MoveCategory(string sourceCategoryId, string targetCategoryId)
{
MoveById(_data.Categories, sourceCategoryId, targetCategoryId, category => category.Id, (category, order) => category.SortOrder = order);
RefreshCategories();
AddLog(LogLevel.Success, "已调整分类顺序。");
_ = SaveAsync();
}
public void MoveTool(string sourceToolId, string targetToolId)
{
var source = _data.Tools.FirstOrDefault(tool => tool.Id == sourceToolId && !tool.IsDeleted);
var target = _data.Tools.FirstOrDefault(tool => tool.Id == targetToolId && !tool.IsDeleted);
if (source is null || target is null || source.Id == target.Id)
{
return;
}
source.CategoryId = target.CategoryId;
var visibleTools = _data.Tools
.Where(tool => !tool.IsDeleted && tool.CategoryId == target.CategoryId)
.OrderBy(tool => tool.SortOrder)
.ThenBy(tool => tool.Name)
.ToList();
MoveById(visibleTools, sourceToolId, targetToolId, tool => tool.Id, (tool, order) => tool.SortOrder = order);
RefreshTools();
AddLog(LogLevel.Success, $"已调整工具顺序:{source.Name}");
_ = SaveAsync();
}
public void MoveToolToCategory(string toolId, string categoryId)
{
var tool = _data.Tools.FirstOrDefault(item => item.Id == toolId && !item.IsDeleted);
var category = _data.Categories.FirstOrDefault(item => item.Id == categoryId);
if (tool is null || category is null)
{
return;
}
tool.CategoryId = category.Id;
tool.SortOrder = NextToolSortOrder();
SelectedCategory = category;
RefreshTools();
SelectedTool = Tools.FirstOrDefault(item => item.Id == tool.Id);
AddLog(LogLevel.Success, $"已移动“{tool.Name}”到分类:{category.Name}");
_ = SaveAsync();
}
private void RaiseSelectionCommandState()
{
LaunchSelectedCommand.RaiseCanExecuteChanged();
EditSelectedCommand.RaiseCanExecuteChanged();
RenameSelectedCommand.RaiseCanExecuteChanged();
DuplicateSelectedCommand.RaiseCanExecuteChanged();
ToggleSelectedAutoRunCommand.RaiseCanExecuteChanged();
MoveSelectedToCategoryCommand.RaiseCanExecuteChanged();
FixSelectedPathCommand.RaiseCanExecuteChanged();
DeleteSelectedCommand.RaiseCanExecuteChanged();
RenameCategoryCommand.RaiseCanExecuteChanged();
EditCategoryIconCommand.RaiseCanExecuteChanged();
DeleteCategoryCommand.RaiseCanExecuteChanged();
}
private static void MoveById<T>(IList<T> items, string sourceId, string targetId, Func<T, string> idSelector, Action<T, int> setOrder)
{
var source = items.FirstOrDefault(item => idSelector(item) == sourceId);
var target = items.FirstOrDefault(item => idSelector(item) == targetId);
if (source is null || target is null || EqualityComparer<T>.Default.Equals(source, target))
{
return;
}
items.Remove(source);
var targetIndex = items.IndexOf(target);
items.Insert(Math.Max(0, targetIndex), source);
for (var index = 0; index < items.Count; index++)
{
setOrder(items[index], index);
}
}
private static void CopyToolValues(ToolItem source, ToolItem target)
{
target.Name = source.Name;

View File

@@ -1,15 +1,24 @@
using PersonalToolbox.Models;
using PersonalToolbox.Services;
namespace PersonalToolbox.ViewModels;
public sealed class ToolCardViewModel : ObservableObject
{
private readonly Func<string, string> _categoryNameResolver;
private readonly Func<string, string?> _iconPathResolver;
private readonly AppSettings _settings;
public ToolCardViewModel(ToolItem tool, Func<string, string> categoryNameResolver)
public ToolCardViewModel(
ToolItem tool,
Func<string, string> categoryNameResolver,
Func<string, string?> iconPathResolver,
AppSettings settings)
{
Tool = tool;
_categoryNameResolver = categoryNameResolver;
_iconPathResolver = iconPathResolver;
_settings = settings;
}
public ToolItem Tool { get; }
@@ -18,7 +27,24 @@ public sealed class ToolCardViewModel : ObservableObject
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 IconText => IconService.GetIconText(Tool.IconKey, Tool.Type);
public string? IconImagePath => _iconPathResolver(Tool.IconKey);
public bool HasIconImage => !string.IsNullOrWhiteSpace(IconImagePath);
public bool ShowDescription => _settings.ShowToolDescriptions;
public double CardWidth => _settings.CardSize switch
{
"Small" => 178,
"Large" => 248,
_ => 210
};
public double CardHeight => _settings.CardSize switch
{
"Small" => 126,
"Large" => 172,
_ => 146
};
public string TypeLabel => Tool.Type switch
{
ToolType.System => "系统",
@@ -72,53 +98,24 @@ public sealed class ToolCardViewModel : ObservableObject
}
}
public string AutoRunMenuHeader => Tool.AutoRunEnabled ? "取消启动时自动运行" : "加入启动时自动运行";
public bool CanFixPath => Tool.Type == ToolType.Local && Tool.PathInvalid;
public void Refresh()
{
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(Description));
OnPropertyChanged(nameof(CategoryName));
OnPropertyChanged(nameof(IconText));
OnPropertyChanged(nameof(IconImagePath));
OnPropertyChanged(nameof(HasIconImage));
OnPropertyChanged(nameof(ShowDescription));
OnPropertyChanged(nameof(CardWidth));
OnPropertyChanged(nameof(CardHeight));
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"
};
OnPropertyChanged(nameof(AutoRunMenuHeader));
OnPropertyChanged(nameof(CanFixPath));
}
}