feat: 补齐工具箱剩余 MVP 能力
实现轻量图标系统、图标选择器、本地图标导入和关联图标缓存。 补齐外观设置、捕获式快捷键录入、卡片右键菜单、分类图标编辑,以及分类和卡片拖拽排序。 同时将配置数据版本升级到 2,并在导入和加载时拒绝更高版本配置,避免误读未来格式。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user