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

@@ -1,10 +1,16 @@
<Application x:Class="PersonalToolbox.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:views="clr-namespace:PersonalToolbox.Views"
StartupUri="MainWindow.xaml">
<Application.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<views:IconKeyToTextConverter x:Key="IconKeyToTextConverter" />
<SolidColorBrush x:Key="AppBackgroundBrush" Color="#F7F8FA" />
<SolidColorBrush x:Key="PanelBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FBFCFE" />
<SolidColorBrush x:Key="BadgeBackgroundBrush" Color="#EDF2F7" />
<SolidColorBrush x:Key="IconBackgroundBrush" Color="#E8F0FF" />
<SolidColorBrush x:Key="BorderBrushSoft" Color="#D9DEE7" />
<SolidColorBrush x:Key="PrimaryBrush" Color="#2563EB" />
<SolidColorBrush x:Key="PrimaryTextBrush" Color="#172033" />

View File

@@ -13,7 +13,7 @@
Background="{StaticResource AppBackgroundBrush}"
Loaded="Window_OnLoaded"
Closing="Window_OnClosing">
<Grid Margin="16">
<Grid x:Name="RootGrid" Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -100,14 +100,32 @@
Margin="0,10,0,10"
ItemsSource="{Binding Categories}"
SelectedItem="{Binding SelectedCategory}"
AllowDrop="True"
BorderThickness="0"
ToolTip="单击切换分类;搜索时会跨分类显示结果。">
ToolTip="单击切换分类;搜索时会跨分类显示结果。可拖拽分类排序,也可把工具拖到分类中。"
PreviewMouseLeftButtonDown="CategoryListBox_OnPreviewMouseLeftButtonDown"
MouseMove="CategoryListBox_OnMouseMove"
Drop="CategoryListBox_OnDrop">
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,2" LastChildFill="True">
<Border Width="32"
Height="26"
CornerRadius="6"
Background="{StaticResource IconBackgroundBrush}"
DockPanel.Dock="Left">
<TextBlock Text="{Binding IconKey, Converter={StaticResource IconKeyToTextConverter}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="11"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryBrush}" />
</Border>
<TextBlock Text="{Binding Name}"
Padding="8,7"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding Name}" />
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
@@ -120,6 +138,10 @@
Margin="0,8,0,0"
Command="{Binding RenameCategoryCommand}"
ToolTip="重命名当前选中的分类。" />
<Button Content="分类图标"
Margin="0,8,0,0"
Command="{Binding EditCategoryIconCommand}"
ToolTip="为当前分类选择内置图标或本地图标。" />
<Button Content="删除分类"
Margin="0,8,0,0"
Command="{Binding DeleteCategoryCommand}"
@@ -137,12 +159,16 @@
<ListBox x:Name="ToolsListBox"
ItemsSource="{Binding Tools}"
SelectedItem="{Binding SelectedTool}"
AllowDrop="True"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
MouseDoubleClick="ToolsListBox_OnMouseDoubleClick"
PreviewMouseRightButtonDown="ToolsListBox_OnPreviewMouseRightButtonDown"
PreviewMouseLeftButtonDown="ToolsListBox_OnPreviewMouseLeftButtonDown"
MouseMove="ToolsListBox_OnMouseMove"
Drop="ToolsListBox_OnDrop"
KeyDown="ToolsListBox_OnKeyDown"
ToolTip="单击选中卡片,双击启动,右键打开管理菜单。">
ToolTip="单击选中卡片,双击启动,右键打开管理菜单。可拖拽调整卡片顺序。">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel IsItemsHost="True" />
@@ -150,23 +176,39 @@
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ToolCardViewModel}">
<Border Width="210"
Height="146"
<Border Width="{Binding CardWidth}"
Height="{Binding CardHeight}"
Margin="6"
Padding="12"
CornerRadius="8"
BorderThickness="1"
BorderBrush="{StaticResource BorderBrushSoft}"
Background="#FBFCFE"
Background="{StaticResource CardBackgroundBrush}"
ToolTip="{Binding DetailText}">
<Border.ContextMenu>
<ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="启动"
ToolTip="启动该工具或组合。"
Click="ContextLaunch_OnClick" />
<MenuItem Header="编辑"
ToolTip="编辑该工具或组合。"
Click="ContextEdit_OnClick" />
<MenuItem Header="重命名"
ToolTip="只修改卡片显示名称。"
Click="ContextRename_OnClick" />
<MenuItem Header="复制"
ToolTip="复制该工具,并清除复制项的快捷键。"
Click="ContextDuplicate_OnClick" />
<MenuItem Header="移动到分类..."
ToolTip="选择一个分类并移动该工具。"
Click="ContextMoveToCategory_OnClick" />
<MenuItem Header="{Binding AutoRunMenuHeader}"
ToolTip="加入或取消工具箱启动时自动运行。"
Click="ContextToggleAutoRun_OnClick" />
<MenuItem Header="修改路径"
IsEnabled="{Binding CanFixPath}"
ToolTip="路径失效时快速打开编辑窗口修改目标路径。"
Click="ContextFixPath_OnClick" />
<MenuItem Header="删除"
ToolTip="删除该工具。"
Click="ContextDelete_OnClick" />
@@ -184,14 +226,41 @@
<Border Width="42"
Height="32"
CornerRadius="6"
Background="#E8F0FF"
Background="{StaticResource IconBackgroundBrush}"
DockPanel.Dock="Left"
ToolTip="工具图标占位;后续会扩展自动图标和图标库。">
ToolTip="工具图标,可来自自动缓存、内置图标库或本地图片。">
<Grid>
<Image Source="{Binding IconImagePath}"
Margin="4"
Stretch="Uniform">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasIconImage}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<TextBlock Text="{Binding IconText}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryBrush}" />
Foreground="{StaticResource PrimaryBrush}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasIconImage}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
</Border>
<TextBlock Text="{Binding TypeLabel}"
HorizontalAlignment="Right"
@@ -215,7 +284,18 @@
Foreground="{StaticResource SecondaryTextBrush}"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding Description}" />
ToolTip="{Binding Description}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding ShowDescription}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<ItemsControl Grid.Row="3"
ItemsSource="{Binding StatusBadges}"
@@ -228,7 +308,7 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#EDF2F7"
<Border Background="{StaticResource BadgeBackgroundBrush}"
CornerRadius="4"
Padding="5,2"
Margin="0,0,5,4">

View File

@@ -17,12 +17,14 @@ public partial class MainWindow : Window
private TrayService? _trayService;
private bool _initialized;
private bool _exitRequested;
private System.Windows.Point _dragStartPoint;
public MainWindow()
{
InitializeComponent();
_viewModel = new MainWindowViewModel();
_viewModel.HotkeysRefreshRequested += (_, _) => RegisterHotkeys();
_viewModel.SettingsChanged += (_, _) => ApplyAppearance();
DataContext = _viewModel;
}
@@ -35,6 +37,7 @@ public partial class MainWindow : Window
_initialized = true;
await _viewModel.InitializeAsync(this);
ApplyAppearance();
Width = _viewModel.Data.Settings.MainWindowWidth;
Height = _viewModel.Data.Settings.MainWindowHeight;
@@ -137,6 +140,46 @@ public partial class MainWindow : Window
}
}
private void ContextRename_OnClick(object sender, RoutedEventArgs e)
{
if (_viewModel.RenameSelectedCommand.CanExecute(null))
{
_viewModel.RenameSelectedCommand.Execute(null);
}
}
private void ContextDuplicate_OnClick(object sender, RoutedEventArgs e)
{
if (_viewModel.DuplicateSelectedCommand.CanExecute(null))
{
_viewModel.DuplicateSelectedCommand.Execute(null);
}
}
private void ContextMoveToCategory_OnClick(object sender, RoutedEventArgs e)
{
if (_viewModel.MoveSelectedToCategoryCommand.CanExecute(null))
{
_viewModel.MoveSelectedToCategoryCommand.Execute(null);
}
}
private void ContextToggleAutoRun_OnClick(object sender, RoutedEventArgs e)
{
if (_viewModel.ToggleSelectedAutoRunCommand.CanExecute(null))
{
_viewModel.ToggleSelectedAutoRunCommand.Execute(null);
}
}
private void ContextFixPath_OnClick(object sender, RoutedEventArgs e)
{
if (_viewModel.FixSelectedPathCommand.CanExecute(null))
{
_viewModel.FixSelectedPathCommand.Execute(null);
}
}
private void ContextDelete_OnClick(object sender, RoutedEventArgs e)
{
if (_viewModel.DeleteSelectedCommand.CanExecute(null))
@@ -161,6 +204,100 @@ public partial class MainWindow : Window
_trayService?.RefreshMenuText();
}
private void CategoryListBox_OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_dragStartPoint = e.GetPosition(null);
}
private void CategoryListBox_OnMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed || !IsDragDistanceReached(e.GetPosition(null)))
{
return;
}
var item = FindParent<ListBoxItem>((DependencyObject)e.OriginalSource);
if (item?.DataContext is CategoryItem category)
{
DragDrop.DoDragDrop(item, new System.Windows.DataObject("PersonalToolboxCategoryId", category.Id), System.Windows.DragDropEffects.Move);
}
}
private void CategoryListBox_OnDrop(object sender, System.Windows.DragEventArgs e)
{
var item = FindParent<ListBoxItem>((DependencyObject)e.OriginalSource);
if (item?.DataContext is not CategoryItem targetCategory)
{
return;
}
if (e.Data.GetDataPresent("PersonalToolboxCategoryId"))
{
var sourceCategoryId = e.Data.GetData("PersonalToolboxCategoryId") as string;
if (!string.IsNullOrWhiteSpace(sourceCategoryId))
{
_viewModel.MoveCategory(sourceCategoryId, targetCategory.Id);
}
}
else if (e.Data.GetDataPresent("PersonalToolboxToolId"))
{
var toolId = e.Data.GetData("PersonalToolboxToolId") as string;
if (!string.IsNullOrWhiteSpace(toolId))
{
_viewModel.MoveToolToCategory(toolId, targetCategory.Id);
}
}
}
private void ToolsListBox_OnPreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
_dragStartPoint = e.GetPosition(null);
}
private void ToolsListBox_OnMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
{
if (e.LeftButton != MouseButtonState.Pressed || !IsDragDistanceReached(e.GetPosition(null)))
{
return;
}
var item = FindParent<ListBoxItem>((DependencyObject)e.OriginalSource);
if (item?.DataContext is ToolCardViewModel tool)
{
DragDrop.DoDragDrop(item, new System.Windows.DataObject("PersonalToolboxToolId", tool.Id), System.Windows.DragDropEffects.Move);
}
}
private void ToolsListBox_OnDrop(object sender, System.Windows.DragEventArgs e)
{
if (!e.Data.GetDataPresent("PersonalToolboxToolId"))
{
return;
}
var sourceToolId = e.Data.GetData("PersonalToolboxToolId") as string;
var item = FindParent<ListBoxItem>((DependencyObject)e.OriginalSource);
if (string.IsNullOrWhiteSpace(sourceToolId) || item?.DataContext is not ToolCardViewModel targetTool)
{
return;
}
_viewModel.MoveTool(sourceToolId, targetTool.Id);
}
private bool IsDragDistanceReached(System.Windows.Point point)
{
return Math.Abs(point.X - _dragStartPoint.X) > SystemParameters.MinimumHorizontalDragDistance
|| Math.Abs(point.Y - _dragStartPoint.Y) > SystemParameters.MinimumVerticalDragDistance;
}
private void ApplyAppearance()
{
AppearanceService.Apply(_viewModel.Data.Settings);
var scale = AppearanceService.NormalizeScale(_viewModel.Data.Settings.UiScale);
RootGrid.LayoutTransform = new ScaleTransform(scale, scale);
}
private void RequestExit()
{
if (_viewModel.Data.Settings.ConfirmExit)

View File

@@ -156,7 +156,7 @@ public sealed class AutoRunEntry
public sealed class AppSettings
{
public int DataVersion { get; set; } = 1;
public int DataVersion { get; set; } = 2;
public bool StartHiddenToTray { get; set; }
public bool HideOnClose { get; set; } = true;
public bool ConfirmExit { get; set; } = true;
@@ -164,6 +164,8 @@ public sealed class AppSettings
public bool LogPanelExpanded { get; set; } = true;
public string Theme { get; set; } = "FollowSystem";
public string CardSize { get; set; } = "Medium";
public double UiScale { get; set; } = 1.0;
public bool ShowToolDescriptions { get; set; } = true;
public double MainWindowWidth { get; set; } = 1120;
public double MainWindowHeight { get; set; } = 720;
}
@@ -178,7 +180,7 @@ public sealed class ToolboxData
public sealed class DataEnvelope<T>
{
public int DataVersion { get; set; } = 1;
public int DataVersion { get; set; } = 2;
public List<T> Items { get; set; } = [];
}

View File

@@ -0,0 +1,63 @@
using System.Windows;
using System.Windows.Media;
using PersonalToolbox.Models;
namespace PersonalToolbox.Services;
public static class AppearanceService
{
public static void Apply(AppSettings settings)
{
var resources = System.Windows.Application.Current.Resources;
var theme = NormalizeTheme(settings.Theme);
SetBrush(resources, "AppBackgroundBrush", theme == "Dark" ? "#151922" : "#F7F8FA");
SetBrush(resources, "PanelBackgroundBrush", theme == "Dark" ? "#1F2530" : "#FFFFFF");
SetBrush(resources, "CardBackgroundBrush", theme == "Dark" ? "#252C38" : "#FBFCFE");
SetBrush(resources, "BadgeBackgroundBrush", theme == "Dark" ? "#303847" : "#EDF2F7");
SetBrush(resources, "IconBackgroundBrush", theme == "Dark" ? "#263D63" : "#E8F0FF");
SetBrush(resources, "BorderBrushSoft", theme == "Dark" ? "#3B4658" : "#D9DEE7");
SetBrush(resources, "PrimaryBrush", theme == "Dark" ? "#7AA2FF" : "#2563EB");
SetBrush(resources, "PrimaryTextBrush", theme == "Dark" ? "#F4F7FB" : "#172033");
SetBrush(resources, "SecondaryTextBrush", theme == "Dark" ? "#B6C0D1" : "#5C667A");
}
public static double NormalizeScale(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 1.0;
}
return Math.Clamp(value, 0.85, 1.30);
}
public static string NormalizeTheme(string? theme)
{
return theme switch
{
"Light" => "Light",
"Dark" => "Dark",
_ => IsSystemDarkTheme() ? "Dark" : "Light"
};
}
private static bool IsSystemDarkTheme()
{
try
{
using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
var value = key?.GetValue("AppsUseLightTheme");
return value is int intValue && intValue == 0;
}
catch
{
return false;
}
}
private static void SetBrush(ResourceDictionary resources, string key, string color)
{
resources[key] = new SolidColorBrush((System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(color));
}
}

View File

@@ -8,7 +8,7 @@ namespace PersonalToolbox.Services;
public sealed class ConfigurationService
{
private const int CurrentDataVersion = 1;
public const int CurrentDataVersion = 2;
private readonly JsonSerializerOptions _jsonOptions = new()
{
WriteIndented = true,
@@ -25,10 +25,12 @@ public sealed class ConfigurationService
private string ToolsPath => Path.Combine(ConfigDirectory, "tools.json");
private string AutoRunPath => Path.Combine(ConfigDirectory, "autorun.json");
public ConfigurationService()
public ConfigurationService(string? configDirectory = null)
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
ConfigDirectory = Path.Combine(appData, "PersonalToolbox");
ConfigDirectory = string.IsNullOrWhiteSpace(configDirectory)
? Path.Combine(appData, "PersonalToolbox")
: configDirectory;
}
public async Task<ToolboxData> LoadAsync(CancellationToken cancellationToken = default)
@@ -36,14 +38,25 @@ public sealed class ConfigurationService
EnsureDirectories();
var toolsFileExists = File.Exists(ToolsPath);
var settings = await ReadFileAsync<AppSettings>(SettingsPath, cancellationToken) ?? new AppSettings();
var categoriesEnvelope = await ReadEnvelopeAsync<CategoryItem>(CategoriesPath, cancellationToken);
var toolsEnvelope = await ReadEnvelopeAsync<ToolItem>(ToolsPath, cancellationToken);
var autoRunEnvelope = await ReadEnvelopeAsync<AutoRunEntry>(AutoRunPath, cancellationToken);
EnsureSupportedDataVersion(settings.DataVersion, "appsettings.json");
EnsureSupportedDataVersion(categoriesEnvelope.DataVersion, "categories.json");
EnsureSupportedDataVersion(toolsEnvelope.DataVersion, "tools.json");
EnsureSupportedDataVersion(autoRunEnvelope.DataVersion, "autorun.json");
var data = new ToolboxData
{
Settings = await ReadFileAsync<AppSettings>(SettingsPath, cancellationToken) ?? new AppSettings(),
Categories = (await ReadEnvelopeAsync<CategoryItem>(CategoriesPath, cancellationToken)).Items,
Tools = (await ReadEnvelopeAsync<ToolItem>(ToolsPath, cancellationToken)).Items,
AutoRunEntries = (await ReadEnvelopeAsync<AutoRunEntry>(AutoRunPath, cancellationToken)).Items
Settings = settings,
Categories = categoriesEnvelope.Items,
Tools = toolsEnvelope.Items,
AutoRunEntries = autoRunEnvelope.Items
};
MigrateData(data);
data.Settings.DataVersion = CurrentDataVersion;
SystemToolService.EnsureDefaultCategories(data.Categories);
if (!toolsFileExists || data.Tools.Count == 0)
@@ -101,13 +114,16 @@ public sealed class ConfigurationService
File.Delete(zipPath);
}
ZipFile.CreateFromDirectory(ConfigDirectory, zipPath, CompressionLevel.Optimal, false);
using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create);
AddFileIfExists(archive, SettingsPath, "appsettings.json");
AddFileIfExists(archive, CategoriesPath, "categories.json");
AddFileIfExists(archive, ToolsPath, "tools.json");
AddFileIfExists(archive, AutoRunPath, "autorun.json");
AddDirectoryIfExists(archive, IconsDirectory, "icons");
}
public async Task<ToolboxData> ImportAsync(string sourcePath, CancellationToken cancellationToken = default)
{
BackupCurrentConfig();
var importDirectory = sourcePath;
var tempDirectory = "";
if (File.Exists(sourcePath) && Path.GetExtension(sourcePath).Equals(".zip", StringComparison.OrdinalIgnoreCase))
@@ -119,6 +135,9 @@ public sealed class ConfigurationService
try
{
ValidateImportDirectory(importDirectory);
BackupCurrentConfig();
CopyIfExists(Path.Combine(importDirectory, "appsettings.json"), SettingsPath);
CopyIfExists(Path.Combine(importDirectory, "categories.json"), CategoriesPath);
CopyIfExists(Path.Combine(importDirectory, "tools.json"), ToolsPath);
@@ -266,4 +285,87 @@ public sealed class ConfigurationService
File.Copy(file, targetPath, true);
}
}
private static void MigrateData(ToolboxData data)
{
data.Settings.Theme = data.Settings.Theme switch
{
"Light" or "Dark" or "FollowSystem" => data.Settings.Theme,
_ => "FollowSystem"
};
data.Settings.CardSize = data.Settings.CardSize switch
{
"Small" or "Medium" or "Large" => data.Settings.CardSize,
_ => "Medium"
};
data.Settings.UiScale = AppearanceService.NormalizeScale(data.Settings.UiScale);
data.Settings.DataVersion = CurrentDataVersion;
foreach (var category in data.Categories.Where(category => string.IsNullOrWhiteSpace(category.IconKey)))
{
category.IconKey = "category";
}
foreach (var tool in data.Tools.Where(tool => string.IsNullOrWhiteSpace(tool.IconKey)))
{
tool.IconKey = tool.Type switch
{
ToolType.System => "system",
ToolType.Url => "link",
ToolType.Combination => "combination",
_ => "local"
};
}
}
private static void ValidateImportDirectory(string importDirectory)
{
EnsureSupportedDataVersion(ReadDataVersion(Path.Combine(importDirectory, "appsettings.json")), "appsettings.json");
EnsureSupportedDataVersion(ReadDataVersion(Path.Combine(importDirectory, "categories.json")), "categories.json");
EnsureSupportedDataVersion(ReadDataVersion(Path.Combine(importDirectory, "tools.json")), "tools.json");
EnsureSupportedDataVersion(ReadDataVersion(Path.Combine(importDirectory, "autorun.json")), "autorun.json");
}
private static int ReadDataVersion(string path)
{
if (!File.Exists(path))
{
return CurrentDataVersion;
}
using var document = JsonDocument.Parse(File.ReadAllText(path));
return document.RootElement.TryGetProperty("dataVersion", out var version) && version.TryGetInt32(out var value)
? value
: 1;
}
private static void EnsureSupportedDataVersion(int dataVersion, string fileName)
{
if (dataVersion > CurrentDataVersion)
{
throw new InvalidDataException($"{fileName} 的数据版本 {dataVersion} 高于当前支持的版本 {CurrentDataVersion}。请升级应用后再导入。");
}
}
private static void AddFileIfExists(ZipArchive archive, string sourcePath, string entryName)
{
if (File.Exists(sourcePath))
{
archive.CreateEntryFromFile(sourcePath, entryName, CompressionLevel.Optimal);
}
}
private static void AddDirectoryIfExists(ZipArchive archive, string sourceDirectory, string entryPrefix)
{
if (!Directory.Exists(sourceDirectory))
{
return;
}
foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(sourceDirectory, file).Replace('\\', '/');
archive.CreateEntryFromFile(file, $"{entryPrefix}/{relativePath}", CompressionLevel.Optimal);
}
}
}

View File

@@ -1,5 +1,6 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using PersonalToolbox.Models;
@@ -214,6 +215,46 @@ public static class HotkeyParser
return key != 0;
}
public static bool TryFormatFromInput(ModifierKeys modifiers, Key key, out string hotkey)
{
hotkey = "";
if ((modifiers & (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Shift | ModifierKeys.Windows)) == 0)
{
return false;
}
var keyText = KeyToText(key);
if (string.IsNullOrWhiteSpace(keyText))
{
return false;
}
var parts = new List<string>();
if (modifiers.HasFlag(ModifierKeys.Control))
{
parts.Add("Ctrl");
}
if (modifiers.HasFlag(ModifierKeys.Alt))
{
parts.Add("Alt");
}
if (modifiers.HasFlag(ModifierKeys.Shift))
{
parts.Add("Shift");
}
if (modifiers.HasFlag(ModifierKeys.Windows))
{
parts.Add("Win");
}
parts.Add(keyText);
hotkey = Normalize(string.Join("+", parts));
return TryParse(hotkey, out _, out _);
}
private static string NormalizePart(string value)
{
var part = value.Trim();
@@ -282,4 +323,47 @@ public static class HotkeyParser
_ => 0
};
}
private static string KeyToText(Key key)
{
if (key is >= Key.A and <= Key.Z)
{
return key.ToString();
}
if (key is >= Key.D0 and <= Key.D9)
{
return ((int)(key - Key.D0)).ToString();
}
if (key is >= Key.NumPad0 and <= Key.NumPad9)
{
return ((int)(key - Key.NumPad0)).ToString();
}
if (key is >= Key.F1 and <= Key.F24)
{
return key.ToString();
}
return key switch
{
Key.Escape => "Esc",
Key.Tab => "Tab",
Key.Space => "Space",
Key.Enter or Key.Return => "Enter",
Key.Back => "Backspace",
Key.Delete => "Delete",
Key.Insert => "Insert",
Key.Home => "Home",
Key.End => "End",
Key.PageUp => "PageUp",
Key.PageDown => "PageDown",
Key.Left => "Left",
Key.Up => "Up",
Key.Right => "Right",
Key.Down => "Down",
_ => ""
};
}
}

View File

@@ -0,0 +1,242 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using System.Security.Cryptography;
using PersonalToolbox.Models;
namespace PersonalToolbox.Services;
public sealed class IconService
{
private readonly string _iconsDirectory;
private readonly string _cacheDirectory;
private readonly string _customDirectory;
public IconService(string iconsDirectory)
{
_iconsDirectory = iconsDirectory;
_cacheDirectory = Path.Combine(_iconsDirectory, "cache");
_customDirectory = Path.Combine(_iconsDirectory, "custom");
}
public void EnsureDirectories()
{
Directory.CreateDirectory(_iconsDirectory);
Directory.CreateDirectory(_cacheDirectory);
Directory.CreateDirectory(_customDirectory);
}
public void EnsureToolIcon(ToolItem tool)
{
if (!ShouldRefreshDefaultIcon(tool))
{
return;
}
tool.IconKey = ResolveDefaultIconKey(tool);
}
public string ResolveDefaultIconKey(ToolItem tool)
{
return tool.Type switch
{
ToolType.System => string.IsNullOrWhiteSpace(tool.IconKey) ? "system" : tool.IconKey,
ToolType.Url => "link",
ToolType.Combination => "combination",
ToolType.Local => ResolveLocalToolIconKey(tool.LaunchTarget),
_ => "toolbox"
};
}
public string? ResolveImagePath(string iconKey)
{
if (string.IsNullOrWhiteSpace(iconKey))
{
return null;
}
var path = iconKey.StartsWith("cache:", StringComparison.OrdinalIgnoreCase)
? Path.Combine(_cacheDirectory, iconKey["cache:".Length..])
: iconKey.StartsWith("custom:", StringComparison.OrdinalIgnoreCase)
? Path.Combine(_customDirectory, iconKey["custom:".Length..])
: iconKey;
return File.Exists(path) ? path : null;
}
public string ImportCustomIcon(string sourcePath)
{
EnsureDirectories();
var extension = Path.GetExtension(sourcePath);
var fileName = $"{Path.GetFileNameWithoutExtension(sourcePath)}_{HashText(sourcePath)}{extension}";
var targetPath = Path.Combine(_customDirectory, fileName);
File.Copy(sourcePath, targetPath, true);
return "custom:" + fileName;
}
public static string GetIconText(string iconKey, ToolType type = ToolType.Local)
{
var builtIn = IconCatalog.Find(iconKey);
if (builtIn is not null)
{
return builtIn.Text;
}
return type switch
{
ToolType.System => "SYS",
ToolType.Local => "APP",
ToolType.Url => "URL",
ToolType.Combination => "COM",
_ => "BOX"
};
}
public static string GetIconName(string iconKey)
{
return IconCatalog.Find(iconKey)?.Name ?? "自定义图标";
}
private string ResolveLocalToolIconKey(string? target)
{
if (string.IsNullOrWhiteSpace(target))
{
return "local";
}
if (Directory.Exists(target))
{
return "folder";
}
if (!File.Exists(target))
{
return "local";
}
var cached = TryCacheAssociatedIcon(target);
if (!string.IsNullOrWhiteSpace(cached))
{
return cached;
}
return Path.GetExtension(target).ToLowerInvariant() switch
{
".bat" or ".cmd" or ".ps1" => "script",
".txt" or ".md" or ".rtf" => "document",
".doc" or ".docx" or ".pdf" or ".xls" or ".xlsx" or ".ppt" or ".pptx" => "document",
".png" or ".jpg" or ".jpeg" or ".gif" or ".bmp" or ".webp" => "image",
".mp3" or ".wav" or ".flac" or ".aac" => "audio",
".mp4" or ".mov" or ".avi" or ".mkv" => "video",
".zip" or ".7z" or ".rar" => "archive",
".cs" or ".js" or ".ts" or ".json" or ".xml" or ".html" or ".css" => "code",
_ => "file"
};
}
private string? TryCacheAssociatedIcon(string path)
{
try
{
EnsureDirectories();
using var icon = Icon.ExtractAssociatedIcon(path);
if (icon is null)
{
return null;
}
var fileName = $"{HashText(path)}.png";
var cachePath = Path.Combine(_cacheDirectory, fileName);
if (!File.Exists(cachePath))
{
using var bitmap = icon.ToBitmap();
bitmap.Save(cachePath, ImageFormat.Png);
}
return "cache:" + fileName;
}
catch
{
return null;
}
}
private static bool ShouldRefreshDefaultIcon(ToolItem tool)
{
if (string.IsNullOrWhiteSpace(tool.IconKey))
{
return true;
}
return tool.Type switch
{
ToolType.Local => tool.IconKey is "toolbox" or "local" or "folder",
ToolType.Url => tool.IconKey is "toolbox" or "link",
ToolType.Combination => tool.IconKey is "toolbox",
ToolType.System => false,
_ => false
};
}
private static string HashText(string value)
{
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
return Convert.ToHexString(hash)[..16].ToLowerInvariant();
}
}
public sealed record IconDefinition(string Key, string Name, string Group, string Text);
public static class IconCatalog
{
private static readonly IReadOnlyList<IconDefinition> Definitions =
[
new("toolbox", "工具箱", "通用", "BOX"),
new("category", "分类", "通用", "TAG"),
new("system", "系统", "系统", "SYS"),
new("settings", "设置", "系统", "SET"),
new("notepad", "记事本", "系统", "TXT"),
new("calculator", "计算器", "系统", "123"),
new("taskmgr", "任务管理器", "系统", "CPU"),
new("control", "控制面板", "系统", "CTL"),
new("device", "设备管理器", "系统", "DEV"),
new("disk", "磁盘", "系统", "DSK"),
new("service", "服务", "系统", "SVC"),
new("registry", "注册表", "系统", "REG"),
new("network", "网络", "系统", "NET"),
new("apps", "应用列表", "系统", "APP"),
new("local", "本地工具", "文件", "APP"),
new("file", "文件", "文件", "FIL"),
new("folder", "文件夹", "文件", "DIR"),
new("document", "文档", "文件", "DOC"),
new("image", "图片", "文件", "IMG"),
new("video", "视频", "文件", "VID"),
new("audio", "音频", "文件", "AUD"),
new("archive", "压缩包", "文件", "ZIP"),
new("code", "代码", "文件", "COD"),
new("script", "脚本", "操作", "CMD"),
new("link", "网址", "操作", "URL"),
new("combination", "组合", "工作区", "COM"),
new("work", "工作", "工作区", "WRK"),
new("study", "学习", "工作区", "STD"),
new("edit", "剪辑", "工作区", "CUT"),
new("design", "设计", "工作区", "DSN"),
new("dev", "开发", "工作区", "DEV"),
new("ai", "AI", "工作区", "AI"),
new("star", "星标", "通用", "STR"),
new("flash", "闪电", "通用", "PWR"),
new("grid", "网格", "通用", "GRD")
];
public static IReadOnlyList<IconDefinition> All => Definitions;
public static IconDefinition? Find(string? key)
{
if (string.IsNullOrWhiteSpace(key) || key.StartsWith("cache:", StringComparison.OrdinalIgnoreCase) || key.StartsWith("custom:", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return Definitions.FirstOrDefault(icon => icon.Key.Equals(key, StringComparison.OrdinalIgnoreCase));
}
}

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

View File

@@ -0,0 +1,38 @@
<Window x:Class="PersonalToolbox.Views.CategoryPickerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="移动到分类"
Width="360"
Height="420"
MinWidth="320"
MinHeight="360"
WindowStartupLocation="CenterOwner">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListBox x:Name="CategoriesListBox"
DisplayMemberPath="Name"
ToolTip="选择工具要移动到的一级分类。"
MouseDoubleClick="CategoriesListBox_OnMouseDoubleClick" />
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button Content="确定"
Width="88"
Margin="0,0,8,0"
IsDefault="True"
ToolTip="移动到选中的分类。"
Click="OkButton_OnClick" />
<Button Content="取消"
Width="88"
IsCancel="True"
ToolTip="不移动工具。"
Click="CancelButton_OnClick" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,45 @@
using System.Windows;
using System.Windows.Input;
using PersonalToolbox.Models;
namespace PersonalToolbox.Views;
public partial class CategoryPickerWindow : Window
{
private CategoryPickerWindow(IEnumerable<CategoryItem> categories, string selectedCategoryId)
{
InitializeComponent();
CategoriesListBox.ItemsSource = categories.OrderBy(category => category.SortOrder).ThenBy(category => category.Name).ToList();
CategoriesListBox.SelectedItem = CategoriesListBox.Items
.OfType<CategoryItem>()
.FirstOrDefault(category => category.Id == selectedCategoryId);
}
public CategoryItem? SelectedCategory { get; private set; }
public static CategoryItem? Select(IEnumerable<CategoryItem> categories, string selectedCategoryId, Window? owner)
{
var window = new CategoryPickerWindow(categories, selectedCategoryId)
{
Owner = owner
};
return window.ShowDialog() == true ? window.SelectedCategory : null;
}
private void OkButton_OnClick(object sender, RoutedEventArgs e)
{
SelectedCategory = CategoriesListBox.SelectedItem as CategoryItem;
DialogResult = SelectedCategory is not null;
}
private void CancelButton_OnClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
private void CategoriesListBox_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
OkButton_OnClick(sender, e);
}
}

View File

@@ -25,6 +25,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="名称" VerticalAlignment="Center" />
@@ -54,12 +55,30 @@
ToolTip="描述这个组合会打开哪些环境或工具。" />
<TextBlock Grid.Row="2" Text="快捷键" VerticalAlignment="Center" />
<TextBox x:Name="HotkeyTextBox"
Grid.Row="2"
<Grid Grid.Row="2"
Grid.Column="1"
Margin="0,8,16,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="HotkeyTextBox"
MinHeight="32"
Margin="0,8,16,0"
ToolTip="格式示例Ctrl + Alt + D。组合可被全局快捷键直接启动。" />
ToolTip="格式示例Ctrl + Alt + D。可点击录入来捕获快捷键。" />
<Button Grid.Column="1"
Content="录入"
Width="64"
Margin="8,0,0,0"
ToolTip="打开快捷键捕获窗口。"
Click="CaptureHotkeyButton_OnClick" />
<Button Grid.Column="2"
Content="清除"
Width="64"
Margin="8,0,0,0"
ToolTip="清除当前快捷键。"
Click="ClearHotkeyButton_OnClick" />
</Grid>
<StackPanel Grid.Row="2"
Grid.Column="2"
@@ -78,6 +97,33 @@
<ComboBoxItem Content="失败后停止" Tag="Stop" />
</ComboBox>
</StackPanel>
<TextBlock Grid.Row="3" Text="图标" VerticalAlignment="Center" Margin="0,12,0,0" />
<StackPanel Grid.Row="3"
Grid.Column="1"
Grid.ColumnSpan="3"
Orientation="Horizontal"
Margin="0,12,0,0">
<Border Width="42"
Height="30"
CornerRadius="6"
Background="{StaticResource IconBackgroundBrush}">
<TextBlock x:Name="IconPreviewTextBlock"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryBrush}" />
</Border>
<TextBlock x:Name="IconNameTextBlock"
Margin="10,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource SecondaryTextBrush}" />
<Button Content="选择图标"
Width="88"
Margin="12,0,0,0"
ToolTip="从内置图标库选择,或导入本地图片/ico。"
Click="ChooseIconButton_OnClick" />
</StackPanel>
</Grid>
<Grid Grid.Row="1" Margin="0,16,0,0">

View File

@@ -13,18 +13,21 @@ public partial class CombinationEditorWindow : Window
private readonly ToolItem _tool;
private readonly IReadOnlyList<ToolItem> _allTools;
private readonly ToolLaunchService _launchService;
private readonly IconService _iconService;
private readonly ObservableCollection<CombinationMemberViewModel> _members = [];
private CombinationEditorWindow(
ToolItem tool,
IEnumerable<CategoryItem> categories,
IEnumerable<ToolItem> allTools,
ToolLaunchService launchService)
ToolLaunchService launchService,
IconService iconService)
{
InitializeComponent();
_tool = tool;
_allTools = allTools.Where(item => !item.IsDeleted).ToList();
_launchService = launchService;
_iconService = iconService;
_tool.Combination ??= new CombinationConfig();
var combination = _tool.Combination;
@@ -39,6 +42,7 @@ public partial class CombinationEditorWindow : Window
HotkeyTextBox.Text = tool.Hotkey;
AutoRunCheckBox.IsChecked = tool.AutoRunEnabled;
FailurePolicyComboBox.SelectedIndex = combination.FailurePolicy == FailurePolicy.Stop ? 1 : 0;
RefreshIconPreview();
foreach (var member in combination.Members.OrderBy(member => member.SortOrder))
{
@@ -53,9 +57,10 @@ public partial class CombinationEditorWindow : Window
IEnumerable<CategoryItem> categories,
IEnumerable<ToolItem> allTools,
ToolLaunchService launchService,
IconService iconService,
Window? owner)
{
var window = new CombinationEditorWindow(tool, categories, allTools, launchService)
var window = new CombinationEditorWindow(tool, categories, allTools, launchService, iconService)
{
Owner = owner
};
@@ -132,7 +137,7 @@ public partial class CombinationEditorWindow : Window
_tool.CategoryId = categoryId;
_tool.Hotkey = string.IsNullOrWhiteSpace(hotkey) ? null : HotkeyParser.Normalize(hotkey);
_tool.AutoRunEnabled = AutoRunCheckBox.IsChecked == true;
_tool.IconKey = "combination";
_tool.IconKey = string.IsNullOrWhiteSpace(_tool.IconKey) ? "combination" : _tool.IconKey;
_tool.Combination = new CombinationConfig
{
FailurePolicy = FailurePolicyComboBox.SelectedIndex == 1 ? FailurePolicy.Stop : FailurePolicy.Continue,
@@ -150,6 +155,35 @@ public partial class CombinationEditorWindow : Window
DialogResult = true;
}
private void CaptureHotkeyButton_OnClick(object sender, RoutedEventArgs e)
{
var hotkey = HotkeyCaptureWindow.Capture(HotkeyTextBox.Text, this, out var clearRequested);
if (clearRequested)
{
HotkeyTextBox.Clear();
return;
}
HotkeyTextBox.Text = hotkey ?? "";
}
private void ClearHotkeyButton_OnClick(object sender, RoutedEventArgs e)
{
HotkeyTextBox.Clear();
}
private void ChooseIconButton_OnClick(object sender, RoutedEventArgs e)
{
var iconKey = IconPickerWindow.SelectIcon(_tool.IconKey, _iconService, this, "combination");
if (string.IsNullOrWhiteSpace(iconKey))
{
return;
}
_tool.IconKey = iconKey;
RefreshIconPreview();
}
private void CancelButton_OnClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
@@ -186,4 +220,10 @@ public partial class CombinationEditorWindow : Window
_members[index].Member.SortOrder = index;
}
}
private void RefreshIconPreview()
{
IconPreviewTextBlock.Text = IconService.GetIconText(_tool.IconKey, ToolType.Combination);
IconNameTextBlock.Text = IconService.GetIconName(_tool.IconKey);
}
}

View File

@@ -0,0 +1,57 @@
<Window x:Class="PersonalToolbox.Views.HotkeyCaptureWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="录入快捷键"
Width="420"
Height="220"
MinWidth="380"
MinHeight="200"
WindowStartupLocation="CenterOwner"
PreviewKeyDown="Window_OnPreviewKeyDown">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border BorderBrush="{StaticResource BorderBrushSoft}"
BorderThickness="1"
CornerRadius="8"
Padding="18"
Background="{StaticResource PanelBackgroundBrush}">
<StackPanel VerticalAlignment="Center">
<TextBlock Text="请按下要使用的快捷键"
FontSize="16"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryTextBrush}" />
<TextBlock Text="需要至少包含 Ctrl、Alt、Shift 或 Win 中的一个修饰键。"
Margin="0,8,0,0"
TextWrapping="Wrap"
Foreground="{StaticResource SecondaryTextBrush}" />
<TextBlock x:Name="CapturedTextBlock"
Text="等待输入..."
Margin="0,18,0,0"
FontSize="22"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryBrush}"
ToolTip="捕获到有效组合后会自动关闭窗口。" />
</StackPanel>
</Border>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button Content="清除"
Width="88"
Margin="0,0,8,0"
ToolTip="清除当前快捷键。"
Click="ClearButton_OnClick" />
<Button Content="取消"
Width="88"
IsCancel="True"
ToolTip="不修改快捷键。"
Click="CancelButton_OnClick" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,63 @@
using System.Windows;
using System.Windows.Input;
using PersonalToolbox.Services;
namespace PersonalToolbox.Views;
public partial class HotkeyCaptureWindow : Window
{
private HotkeyCaptureWindow(string? currentHotkey)
{
InitializeComponent();
CapturedTextBlock.Text = string.IsNullOrWhiteSpace(currentHotkey) ? "等待输入..." : currentHotkey;
Loaded += (_, _) => Focus();
}
public string? CapturedHotkey { get; private set; }
public bool ClearRequested { get; private set; }
public static string? Capture(string? currentHotkey, Window? owner, out bool clearRequested)
{
var window = new HotkeyCaptureWindow(currentHotkey)
{
Owner = owner
};
var accepted = window.ShowDialog() == true;
clearRequested = accepted && window.ClearRequested;
return accepted ? window.CapturedHotkey : currentHotkey;
}
private void Window_OnPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
var key = e.Key == Key.System ? e.SystemKey : e.Key == Key.ImeProcessed ? e.ImeProcessedKey : e.Key;
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftAlt or Key.RightAlt or Key.LeftShift or Key.RightShift or Key.LWin or Key.RWin)
{
e.Handled = true;
return;
}
if (!HotkeyParser.TryFormatFromInput(Keyboard.Modifiers, key, out var hotkey))
{
CapturedTextBlock.Text = "请包含修饰键";
e.Handled = true;
return;
}
CapturedHotkey = hotkey;
DialogResult = true;
e.Handled = true;
}
private void ClearButton_OnClick(object sender, RoutedEventArgs e)
{
CapturedHotkey = null;
ClearRequested = true;
DialogResult = true;
}
private void CancelButton_OnClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
}

View File

@@ -0,0 +1,18 @@
using System.Globalization;
using System.Windows.Data;
using PersonalToolbox.Services;
namespace PersonalToolbox.Views;
public sealed class IconKeyToTextConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return IconService.GetIconText(value as string ?? "", Models.ToolType.Local);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return System.Windows.Data.Binding.DoNothing;
}
}

View File

@@ -0,0 +1,82 @@
<Window x:Class="PersonalToolbox.Views.IconPickerWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="选择图标"
Width="520"
Height="520"
MinWidth="460"
MinHeight="440"
WindowStartupLocation="CenterOwner">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal">
<Button Content="自动/默认"
Width="96"
ToolTip="恢复为该工具或分类的默认图标。"
Click="DefaultButton_OnClick" />
<Button Content="选择本地图片"
Width="120"
Margin="8,0,0,0"
ToolTip="复制 png、jpg、bmp 或 ico 到配置目录的 icons/custom。"
Click="LocalIconButton_OnClick" />
</StackPanel>
<ListBox x:Name="IconsListBox"
Grid.Row="1"
Margin="0,12,0,0"
ToolTip="从轻量内置图标库中选择一个图标。"
MouseDoubleClick="IconsListBox_OnMouseDoubleClick">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="4" MinHeight="38">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="52" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="96" />
</Grid.ColumnDefinitions>
<Border Width="42"
Height="30"
CornerRadius="6"
Background="{StaticResource IconBackgroundBrush}">
<TextBlock Text="{Binding Text}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryBrush}" />
</Border>
<TextBlock Grid.Column="1"
Text="{Binding Name}"
VerticalAlignment="Center"
Foreground="{StaticResource PrimaryTextBrush}" />
<TextBlock Grid.Column="2"
Text="{Binding Group}"
VerticalAlignment="Center"
Foreground="{StaticResource SecondaryTextBrush}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,12,0,0">
<Button Content="确定"
Width="88"
Margin="0,0,8,0"
IsDefault="True"
ToolTip="使用选中的图标。"
Click="OkButton_OnClick" />
<Button Content="取消"
Width="88"
IsCancel="True"
ToolTip="不修改图标。"
Click="CancelButton_OnClick" />
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Input;
using PersonalToolbox.Services;
using OpenFileDialog = Microsoft.Win32.OpenFileDialog;
namespace PersonalToolbox.Views;
public partial class IconPickerWindow : Window
{
private readonly IconService _iconService;
private readonly string _defaultIconKey;
private IconPickerWindow(string currentIconKey, string defaultIconKey, IconService iconService)
{
InitializeComponent();
_defaultIconKey = defaultIconKey;
_iconService = iconService;
IconsListBox.ItemsSource = IconCatalog.All
.OrderBy(icon => icon.Group)
.ThenBy(icon => icon.Name)
.ToList();
IconsListBox.SelectedItem = IconsListBox.Items
.OfType<IconDefinition>()
.FirstOrDefault(icon => icon.Key.Equals(currentIconKey, StringComparison.OrdinalIgnoreCase));
}
public string? SelectedIconKey { get; private set; }
public static string? SelectIcon(string currentIconKey, IconService iconService, Window? owner, string defaultIconKey = "toolbox")
{
var window = new IconPickerWindow(currentIconKey, defaultIconKey, iconService)
{
Owner = owner
};
return window.ShowDialog() == true ? window.SelectedIconKey : null;
}
private void DefaultButton_OnClick(object sender, RoutedEventArgs e)
{
SelectedIconKey = _defaultIconKey;
DialogResult = true;
}
private void LocalIconButton_OnClick(object sender, RoutedEventArgs e)
{
var dialog = new OpenFileDialog
{
Title = "选择本地图标",
Filter = "图片或图标 (*.png;*.jpg;*.jpeg;*.bmp;*.ico)|*.png;*.jpg;*.jpeg;*.bmp;*.ico|所有文件 (*.*)|*.*"
};
if (dialog.ShowDialog(this) != true)
{
return;
}
try
{
SelectedIconKey = _iconService.ImportCustomIcon(dialog.FileName);
DialogResult = true;
}
catch (Exception ex)
{
System.Windows.MessageBox.Show(this, $"导入图标失败:{ex.Message}", "选择图标", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private void OkButton_OnClick(object sender, RoutedEventArgs e)
{
if (IconsListBox.SelectedItem is not IconDefinition icon)
{
System.Windows.MessageBox.Show(this, "请选择一个图标。", "选择图标", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
SelectedIconKey = icon.Key;
DialogResult = true;
}
private void CancelButton_OnClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
private void IconsListBox_OnMouseDoubleClick(object sender, MouseButtonEventArgs e)
{
OkButton_OnClick(sender, e);
}
}

View File

@@ -43,6 +43,77 @@
</StackPanel>
</TabItem>
<TabItem Header="外观" ToolTip="管理主题、卡片大小、界面缩放和说明显示。">
<Grid Margin="18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="130" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="主题" VerticalAlignment="Center" />
<ComboBox x:Name="ThemeComboBox"
Grid.Column="1"
Width="180"
HorizontalAlignment="Left"
ToolTip="可跟随系统,也可固定为浅色或深色。">
<ComboBoxItem Content="跟随系统" Tag="FollowSystem" />
<ComboBoxItem Content="浅色" Tag="Light" />
<ComboBoxItem Content="深色" Tag="Dark" />
</ComboBox>
<TextBlock Grid.Row="1"
Text="卡片大小"
VerticalAlignment="Center"
Margin="0,14,0,0" />
<ComboBox x:Name="CardSizeComboBox"
Grid.Row="1"
Grid.Column="1"
Width="180"
HorizontalAlignment="Left"
Margin="0,14,0,0"
ToolTip="调整主界面卡片宽高。">
<ComboBoxItem Content="小" Tag="Small" />
<ComboBoxItem Content="中" Tag="Medium" />
<ComboBoxItem Content="大" Tag="Large" />
</ComboBox>
<TextBlock Grid.Row="2"
Text="界面缩放"
VerticalAlignment="Center"
Margin="0,18,0,0" />
<StackPanel Grid.Row="2"
Grid.Column="1"
Orientation="Horizontal"
Margin="0,18,0,0">
<Slider x:Name="ScaleSlider"
Width="220"
Minimum="0.85"
Maximum="1.30"
TickFrequency="0.05"
IsSnapToTickEnabled="True"
ToolTip="缩放主界面内容,适合不同显示器密度。" />
<TextBlock Text="{Binding ElementName=ScaleSlider, Path=Value, StringFormat={}{0:P0}}"
Width="54"
Margin="12,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource SecondaryTextBrush}" />
</StackPanel>
<CheckBox x:Name="ShowDescriptionCheckBox"
Grid.Row="3"
Grid.Column="1"
Content="在卡片上显示工具说明"
Margin="0,18,0,0"
ToolTip="关闭后卡片更紧凑,但仍可通过悬浮提示查看详情。" />
</Grid>
</TabItem>
<TabItem Header="自动运行" ToolTip="动态汇总所有开启启动时自动运行的工具。">
<Grid Margin="18">
<Grid.RowDefinitions>
@@ -98,10 +169,14 @@
<TabItem Header="快捷键" ToolTip="查看和编辑工具快捷键状态。">
<Grid Margin="18">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<DataGrid x:Name="HotkeysDataGrid"
AutoGenerateColumns="False"
CanUserAddRows="False"
ToolTip="快捷键状态会在主窗口注册后更新;可直接编辑快捷键文本。">
ToolTip="快捷键状态会在主窗口注册后更新;可录入、编辑或清除快捷键。">
<DataGrid.Columns>
<DataGridTextColumn Header="快捷键"
Width="160"
@@ -120,6 +195,20 @@
Binding="{Binding HotkeyStatus}" />
</DataGrid.Columns>
</DataGrid>
<StackPanel Grid.Row="1"
Orientation="Horizontal"
HorizontalAlignment="Right"
Margin="0,10,0,0">
<Button Content="录入快捷键"
Width="96"
Margin="0,0,8,0"
ToolTip="为选中的工具捕获一个快捷键。"
Click="CaptureHotkeyButton_OnClick" />
<Button Content="清除"
Width="76"
ToolTip="清除选中工具的快捷键。"
Click="ClearHotkeyButton_OnClick" />
</StackPanel>
</Grid>
</TabItem>

View File

@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using PersonalToolbox.Models;
using PersonalToolbox.Services;
using MessageBox = System.Windows.MessageBox;
@@ -44,10 +45,14 @@ public partial class SettingsWindow : Window
LogExpandedCheckBox.IsChecked = _data.Settings.LogPanelExpanded;
GlobalHotkeysCheckBox.IsChecked = _data.Settings.GlobalHotkeysEnabled;
StartupCheckBox.IsChecked = _startupService.IsEnabled();
SelectComboBoxItemByTag(ThemeComboBox, _data.Settings.Theme);
SelectComboBoxItemByTag(CardSizeComboBox, _data.Settings.CardSize);
ScaleSlider.Value = AppearanceService.NormalizeScale(_data.Settings.UiScale);
ShowDescriptionCheckBox.IsChecked = _data.Settings.ShowToolDescriptions;
RebuildAutoRunRows();
AutoRunDataGrid.ItemsSource = _autoRunRows;
HotkeysDataGrid.ItemsSource = _data.Tools.Where(tool => !tool.IsDeleted && !string.IsNullOrWhiteSpace(tool.Hotkey)).OrderBy(tool => tool.Name).ToList();
HotkeysDataGrid.ItemsSource = _data.Tools.Where(tool => !tool.IsDeleted).OrderBy(tool => tool.Name).ToList();
}
private void RebuildAutoRunRows()
@@ -73,12 +78,21 @@ public partial class SettingsWindow : Window
_data.Settings.ConfirmExit = ConfirmExitCheckBox.IsChecked == true;
_data.Settings.LogPanelExpanded = LogExpandedCheckBox.IsChecked == true;
_data.Settings.GlobalHotkeysEnabled = GlobalHotkeysCheckBox.IsChecked == true;
_data.Settings.Theme = GetSelectedTag(ThemeComboBox, "FollowSystem");
_data.Settings.CardSize = GetSelectedTag(CardSizeComboBox, "Medium");
_data.Settings.UiScale = AppearanceService.NormalizeScale(ScaleSlider.Value);
_data.Settings.ShowToolDescriptions = ShowDescriptionCheckBox.IsChecked == true;
foreach (var row in _autoRunRows)
{
row.Apply();
}
if (!ValidateAndNormalizeHotkeys())
{
return;
}
_data.AutoRunEntries = _autoRunRows.Select(row => row.Entry).OrderBy(row => row.SortOrder).ToList();
try
@@ -211,6 +225,30 @@ public partial class SettingsWindow : Window
RebuildAutoRunRows();
}
private void CaptureHotkeyButton_OnClick(object sender, RoutedEventArgs e)
{
if (HotkeysDataGrid.SelectedItem is not ToolItem tool)
{
return;
}
var hotkey = HotkeyCaptureWindow.Capture(tool.Hotkey, this, out var clearRequested);
tool.Hotkey = clearRequested ? null : hotkey;
HotkeysDataGrid.Items.Refresh();
}
private void ClearHotkeyButton_OnClick(object sender, RoutedEventArgs e)
{
if (HotkeysDataGrid.SelectedItem is not ToolItem tool)
{
return;
}
tool.Hotkey = null;
tool.HotkeyStatus = "未设置";
HotkeysDataGrid.Items.Refresh();
}
private void MoveAutoRunRow(int offset)
{
if (AutoRunDataGrid.SelectedItem is not AutoRunRow selected)
@@ -238,6 +276,23 @@ public partial class SettingsWindow : Window
}
}
private bool ValidateAndNormalizeHotkeys()
{
foreach (var tool in _data.Tools.Where(tool => !tool.IsDeleted && !string.IsNullOrWhiteSpace(tool.Hotkey)))
{
var hotkey = tool.Hotkey!.Trim();
if (!HotkeyParser.TryParse(hotkey, out _, out _))
{
MessageBox.Show(this, $"“{tool.Name}”的快捷键格式无效,请重新录入。", "保存设置", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
tool.Hotkey = HotkeyParser.Normalize(hotkey);
}
return true;
}
private static ToolboxData CloneData(ToolboxData source)
{
return new ToolboxData
@@ -252,6 +307,8 @@ public partial class SettingsWindow : Window
LogPanelExpanded = source.Settings.LogPanelExpanded,
Theme = source.Settings.Theme,
CardSize = source.Settings.CardSize,
UiScale = source.Settings.UiScale,
ShowToolDescriptions = source.Settings.ShowToolDescriptions,
MainWindowWidth = source.Settings.MainWindowWidth,
MainWindowHeight = source.Settings.MainWindowHeight
},
@@ -301,4 +358,19 @@ public partial class SettingsWindow : Window
Entry.IntervalAfterMs = Math.Max(0, IntervalAfterMs);
}
}
private static void SelectComboBoxItemByTag(System.Windows.Controls.ComboBox comboBox, string value)
{
comboBox.SelectedItem = comboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), value, StringComparison.OrdinalIgnoreCase))
?? comboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static string GetSelectedTag(System.Windows.Controls.ComboBox comboBox, string fallback)
{
return comboBox.SelectedItem is ComboBoxItem item && item.Tag is not null
? item.Tag.ToString() ?? fallback
: fallback;
}
}

View File

@@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="编辑工具"
Width="560"
Height="560"
Height="620"
MinWidth="520"
WindowStartupLocation="CenterOwner">
<Grid Margin="18">
@@ -113,12 +113,30 @@
ToolTip="可执行文件或脚本启动时使用的工作目录,可留空。" />
<TextBlock Grid.Row="7" Text="快捷键" VerticalAlignment="Center" />
<TextBox x:Name="HotkeyTextBox"
Grid.Row="7"
<Grid Grid.Row="7"
Grid.Column="1"
Margin="0,8,0,0"
Margin="0,8,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="HotkeyTextBox"
MinHeight="32"
ToolTip="格式示例Ctrl + Alt + T。第一版要求至少包含一个修饰键。" />
ToolTip="格式示例Ctrl + Alt + T。可点击录入来捕获快捷键。" />
<Button Grid.Column="1"
Content="录入"
Width="64"
Margin="8,0,0,0"
ToolTip="打开快捷键捕获窗口。"
Click="CaptureHotkeyButton_OnClick" />
<Button Grid.Column="2"
Content="清除"
Width="64"
Margin="8,0,0,0"
ToolTip="清除当前快捷键。"
Click="ClearHotkeyButton_OnClick" />
</Grid>
<StackPanel Grid.Row="8"
Grid.Column="1"
@@ -131,6 +149,32 @@
Margin="0,8,0,0"
ToolTip="仅启动该工具时触发 UAC工具箱自身不提权。" />
</StackPanel>
<TextBlock Grid.Row="9" Text="图标" VerticalAlignment="Center" />
<StackPanel Grid.Row="9"
Grid.Column="1"
Orientation="Horizontal"
Margin="0,12,0,0">
<Border Width="42"
Height="30"
CornerRadius="6"
Background="{StaticResource IconBackgroundBrush}">
<TextBlock x:Name="IconPreviewTextBlock"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{StaticResource PrimaryBrush}" />
</Border>
<TextBlock x:Name="IconNameTextBlock"
Margin="10,0,0,0"
VerticalAlignment="Center"
Foreground="{StaticResource SecondaryTextBrush}" />
<Button Content="选择图标"
Width="88"
Margin="12,0,0,0"
ToolTip="从内置图标库选择,或导入本地图片/ico。"
Click="ChooseIconButton_OnClick" />
</StackPanel>
</Grid>
</ScrollViewer>

View File

@@ -10,11 +10,13 @@ namespace PersonalToolbox.Views;
public partial class ToolEditorWindow : Window
{
private readonly ToolItem _tool;
private readonly IconService _iconService;
private ToolEditorWindow(ToolItem tool, IEnumerable<CategoryItem> categories)
private ToolEditorWindow(ToolItem tool, IEnumerable<CategoryItem> categories, IconService iconService)
{
InitializeComponent();
_tool = tool;
_iconService = iconService;
CategoryComboBox.ItemsSource = categories.ToList();
CategoryComboBox.SelectedValue = tool.CategoryId;
@@ -43,6 +45,7 @@ public partial class ToolEditorWindow : Window
HotkeyTextBox.Text = tool.Hotkey;
AutoRunCheckBox.IsChecked = tool.AutoRunEnabled;
RunAsAdminCheckBox.IsChecked = tool.RunAsAdmin;
RefreshIconPreview();
if (tool.Type == ToolType.Url)
{
@@ -60,9 +63,9 @@ public partial class ToolEditorWindow : Window
public ToolItem EditedTool => _tool;
public static ToolItem? Edit(ToolItem tool, IEnumerable<CategoryItem> categories, Window? owner)
public static ToolItem? Edit(ToolItem tool, IEnumerable<CategoryItem> categories, IconService iconService, Window? owner)
{
var window = new ToolEditorWindow(tool, categories)
var window = new ToolEditorWindow(tool, categories, iconService)
{
Owner = owner
};
@@ -99,6 +102,39 @@ public partial class ToolEditorWindow : Window
}
}
private void CaptureHotkeyButton_OnClick(object sender, RoutedEventArgs e)
{
var hotkey = HotkeyCaptureWindow.Capture(HotkeyTextBox.Text, this, out var clearRequested);
if (clearRequested)
{
HotkeyTextBox.Clear();
return;
}
HotkeyTextBox.Text = hotkey ?? "";
}
private void ClearHotkeyButton_OnClick(object sender, RoutedEventArgs e)
{
HotkeyTextBox.Clear();
}
private void ChooseIconButton_OnClick(object sender, RoutedEventArgs e)
{
var previewTool = _tool.Clone();
previewTool.LaunchTarget = _tool.Type == ToolType.Url ? null : TargetTextBox.Text.Trim();
previewTool.Url = _tool.Type == ToolType.Url ? TargetTextBox.Text.Trim() : null;
var defaultKey = _iconService.ResolveDefaultIconKey(previewTool);
var iconKey = IconPickerWindow.SelectIcon(_tool.IconKey, _iconService, this, defaultKey);
if (string.IsNullOrWhiteSpace(iconKey))
{
return;
}
_tool.IconKey = iconKey;
RefreshIconPreview();
}
private void SaveButton_OnClick(object sender, RoutedEventArgs e)
{
var name = NameTextBox.Text.Trim();
@@ -141,6 +177,7 @@ public partial class ToolEditorWindow : Window
_tool.Name = name;
_tool.Description = DescriptionTextBox.Text.Trim();
_tool.CategoryId = categoryId;
_tool.IconKey = string.IsNullOrWhiteSpace(_tool.IconKey) ? _iconService.ResolveDefaultIconKey(_tool) : _tool.IconKey;
_tool.Arguments = ArgumentsTextBox.Text.Trim();
_tool.WorkingDirectory = WorkingDirectoryTextBox.Text.Trim();
_tool.Hotkey = string.IsNullOrWhiteSpace(hotkey) ? null : HotkeyParser.Normalize(hotkey);
@@ -150,6 +187,12 @@ public partial class ToolEditorWindow : Window
DialogResult = true;
}
private void RefreshIconPreview()
{
IconPreviewTextBlock.Text = IconService.GetIconText(_tool.IconKey, _tool.Type);
IconNameTextBlock.Text = IconService.GetIconName(_tool.IconKey);
}
private void CancelButton_OnClick(object sender, RoutedEventArgs e)
{
DialogResult = false;