feat: 初始提交——一键运行快捷轮盘工具(WPF + C#)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<Application x:Class="OneClickRun.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:OneClickRun.Converters"
|
||||
ShutdownMode="OnExplicitShutdown">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceDictionary Source="Themes/Light.xaml"/>
|
||||
<ResourceDictionary Source="Themes/Styles.xaml"/>
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<converters:EnumToDisplayConverter x:Key="EnumDisplay"/>
|
||||
<converters:EnumEqualsConverter x:Key="EnumEquals"/>
|
||||
<converters:EnumToBadgeBrushConverter x:Key="EnumBadge"/>
|
||||
<converters:CountToVisibilityConverter x:Key="CountToVisibility"/>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using WinForms = System.Windows.Forms;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Services;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private const string SingleInstanceMutexName = @"GlobalOneClickRun_SingleInstance";
|
||||
private const string ShowSettingsMessageName = "OneClickRun.ShowSettings";
|
||||
|
||||
private Mutex? _mutex;
|
||||
private WinForms.NotifyIcon? _tray;
|
||||
private HotkeyHostWindow? _hotkeyHost;
|
||||
private MainWindow? _main;
|
||||
private bool _exitRequested;
|
||||
private string[] _startupArgs = Array.Empty<string>();
|
||||
private bool _snapshotMode;
|
||||
|
||||
public ConfigService Config { get; } = new();
|
||||
public HotkeyService Hotkeys { get; } = new();
|
||||
public WheelWindowManager? Wheel { get; private set; }
|
||||
public ThemeService Themes { get; } = new();
|
||||
public AutostartService Autostart { get; } = new();
|
||||
public CommandRunner Runner { get; } = new();
|
||||
|
||||
public bool IsExitRequested => _exitRequested;
|
||||
public static uint ShowSettingsMessage { get; private set; }
|
||||
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
_startupArgs = e.Args;
|
||||
Logger.Init();
|
||||
Logger.Info($"一键运行启动 v{Assembly.GetExecutingAssembly().GetName().Version}");
|
||||
|
||||
_mutex = new Mutex(true, SingleInstanceMutexName, out bool createdNew);
|
||||
if (!createdNew)
|
||||
{
|
||||
Logger.Info("检测到已有实例运行,唤起其设置窗口后退出");
|
||||
try
|
||||
{
|
||||
var msg = Win32Interop.RegisterWindowMessage(ShowSettingsMessageName);
|
||||
Win32Interop.PostMessage(Win32Interop.HWND_BROADCAST, msg, IntPtr.Zero, IntPtr.Zero);
|
||||
}
|
||||
catch { /* 忽略 */ }
|
||||
Shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
{
|
||||
Logger.Error("未处理异常", args.Exception);
|
||||
ToastService.Show("发生错误:" + args.Exception.Message);
|
||||
args.Handled = true;
|
||||
};
|
||||
|
||||
ShowSettingsMessage = Win32Interop.RegisterWindowMessage(ShowSettingsMessageName);
|
||||
Config.Load();
|
||||
Themes.Apply(Config.Current.General.Theme);
|
||||
Wheel = new WheelWindowManager(Runner, () => Config.Current);
|
||||
|
||||
// 不可见宿主窗口:为全局热键与广播消息提供稳定 HWND
|
||||
_hotkeyHost = new HotkeyHostWindow();
|
||||
_hotkeyHost.Show();
|
||||
Hotkeys.Attach(_hotkeyHost);
|
||||
Hotkeys.HotkeyPressed += () => Wheel?.OnHotkeyPressed();
|
||||
Hotkeys.RegistrationFailed += () =>
|
||||
{
|
||||
ToastService.Show("全局快捷键注册失败,可能与其他程序冲突,请在设置中更换快捷键");
|
||||
_main?.NotifyHotkeyStatus();
|
||||
};
|
||||
|
||||
_main = new MainWindow();
|
||||
_main.HookShowSettingsMessage(ShowSettingsMessage);
|
||||
ApplyAll();
|
||||
BuildTray();
|
||||
|
||||
// 内部调试:--snapshot <路径> [--dark] [--hover N] 导出轮盘 PNG 后退出,不保存配置
|
||||
var snapshotPath = GetArgValue("--snapshot");
|
||||
if (snapshotPath != null)
|
||||
{
|
||||
RunSnapshotMode(snapshotPath, HasArg("--dark"), GetArgInt("--hover"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Config.WasFirstRun)
|
||||
{
|
||||
Logger.Info("首次运行,显示设置窗口");
|
||||
ShowMainWindow();
|
||||
}
|
||||
Logger.Info("启动完成");
|
||||
}
|
||||
|
||||
/// <summary>将当前配置应用到运行中的各服务(热键、主题、轮盘开关、开机启动)</summary>
|
||||
public void ApplyAll()
|
||||
{
|
||||
Hotkeys.Update(Config.Current.General.Hotkey);
|
||||
Themes.Apply(Config.Current.General.Theme);
|
||||
if (Wheel != null) Wheel.Enabled = Config.Current.General.WheelEnabled;
|
||||
Autostart.SetEnabled(Config.Current.General.AutoStart);
|
||||
}
|
||||
|
||||
public void ShowMainWindow() => _main?.ShowSettingsAndActivate();
|
||||
|
||||
public void RequestExit()
|
||||
{
|
||||
_exitRequested = true;
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
private bool HasArg(string name) => _startupArgs.Contains(name, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private int? GetArgInt(string name)
|
||||
{
|
||||
var value = GetArgValue(name);
|
||||
return value != null && int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private string? GetArgValue(string name)
|
||||
{
|
||||
for (var i = 0; i < _startupArgs.Length - 1; i++)
|
||||
if (string.Equals(_startupArgs[i], name, StringComparison.OrdinalIgnoreCase))
|
||||
return _startupArgs[i + 1];
|
||||
return null;
|
||||
}
|
||||
|
||||
private void RunSnapshotMode(string path, bool dark, int? hoverIndex)
|
||||
{
|
||||
_snapshotMode = true;
|
||||
if (dark) Themes.Apply(Models.ThemeMode.Dark);
|
||||
Wheel!.Show();
|
||||
Dispatcher.BeginInvoke(async () =>
|
||||
{
|
||||
await Task.Delay(600);
|
||||
if (hoverIndex is int index)
|
||||
Wheel.Window.SimulateHover(index);
|
||||
await Task.Delay(600);
|
||||
try { SnapshotWheel(path); }
|
||||
catch (Exception ex) { Logger.Error("导出轮盘快照失败", ex); }
|
||||
Shutdown();
|
||||
}, DispatcherPriority.ApplicationIdle);
|
||||
}
|
||||
|
||||
private void SnapshotWheel(string path)
|
||||
{
|
||||
var window = Wheel!.Window;
|
||||
var canvas = window.Root;
|
||||
canvas.Measure(new Size(window.Width, window.Height));
|
||||
canvas.Arrange(new Rect(0, 0, window.Width, window.Height));
|
||||
canvas.UpdateLayout();
|
||||
var bitmap = new RenderTargetBitmap(
|
||||
(int)Math.Ceiling(window.Width), (int)Math.Ceiling(window.Height), 96, 96, PixelFormats.Pbgra32);
|
||||
bitmap.Render(canvas);
|
||||
var encoder = new PngBitmapEncoder();
|
||||
encoder.Frames.Add(BitmapFrame.Create(bitmap));
|
||||
var full = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full) ?? ".");
|
||||
using var stream = File.Create(full);
|
||||
encoder.Save(stream);
|
||||
Logger.Info($"轮盘快照已保存: {full}");
|
||||
}
|
||||
|
||||
protected override void OnExit(ExitEventArgs e)
|
||||
{
|
||||
Logger.Info("退出");
|
||||
if (!_snapshotMode) { try { Config.Save(); } catch { } }
|
||||
try { Hotkeys.Dispose(); } catch { }
|
||||
try { Wheel?.Dispose(); } catch { }
|
||||
try { _tray?.Dispose(); } catch { }
|
||||
try { _mutex?.ReleaseMutex(); } catch { }
|
||||
base.OnExit(e);
|
||||
}
|
||||
|
||||
private void BuildTray()
|
||||
{
|
||||
_tray = new WinForms.NotifyIcon
|
||||
{
|
||||
Icon = AppIcon.GetTrayIcon(),
|
||||
Text = "一键运行",
|
||||
Visible = true,
|
||||
};
|
||||
|
||||
var menu = new WinForms.ContextMenuStrip();
|
||||
menu.Items.Add("打开设置", null, (_, _) => ShowMainWindow());
|
||||
menu.Items.Add("显示轮盘", null, (_, _) => Wheel?.Show());
|
||||
|
||||
var autoStartItem = new WinForms.ToolStripMenuItem("开机启动")
|
||||
{
|
||||
Checked = Config.Current.General.AutoStart,
|
||||
};
|
||||
autoStartItem.Click += (_, _) =>
|
||||
{
|
||||
autoStartItem.Checked = !autoStartItem.Checked;
|
||||
Config.Current.General.AutoStart = autoStartItem.Checked;
|
||||
Config.Save();
|
||||
Autostart.SetEnabled(autoStartItem.Checked);
|
||||
};
|
||||
menu.Items.Add(autoStartItem);
|
||||
menu.Items.Add(new WinForms.ToolStripSeparator());
|
||||
menu.Items.Add("退出", null, (_, _) => RequestExit());
|
||||
|
||||
_tray.ContextMenuStrip = menu;
|
||||
_tray.DoubleClick += (_, _) => ShowMainWindow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using OneClickRun.Helpers;
|
||||
|
||||
namespace OneClickRun.Controls;
|
||||
|
||||
/// <summary>快捷键录制控件:点击后按下组合键完成录制</summary>
|
||||
public class HotkeyRecorder : UserControl
|
||||
{
|
||||
public static readonly DependencyProperty HotkeyTextProperty = DependencyProperty.Register(
|
||||
nameof(HotkeyText), typeof(string), typeof(HotkeyRecorder),
|
||||
new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnVisualChanged));
|
||||
|
||||
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register(
|
||||
nameof(HasConflict), typeof(bool), typeof(HotkeyRecorder),
|
||||
new PropertyMetadata(false, OnVisualChanged));
|
||||
|
||||
private readonly Border _border;
|
||||
private readonly TextBlock _text;
|
||||
|
||||
public HotkeyRecorder()
|
||||
{
|
||||
_text = new TextBlock
|
||||
{
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
};
|
||||
_border = new Border
|
||||
{
|
||||
Child = _text,
|
||||
CornerRadius = new CornerRadius(8),
|
||||
BorderThickness = new Thickness(1),
|
||||
Padding = new Thickness(12, 0, 12, 0),
|
||||
Height = 34,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
_border.SetResourceReference(Border.BackgroundProperty, "InputBackgroundBrush");
|
||||
_border.SetResourceReference(Border.BorderBrushProperty, "BorderBrush");
|
||||
_text.SetResourceReference(TextBlock.ForegroundProperty, "TextPrimaryBrush");
|
||||
|
||||
Content = _border;
|
||||
Focusable = true;
|
||||
PreviewKeyDown += OnPreviewKeyDown;
|
||||
UpdateVisuals();
|
||||
}
|
||||
|
||||
public string HotkeyText
|
||||
{
|
||||
get => (string)GetValue(HotkeyTextProperty);
|
||||
set => SetValue(HotkeyTextProperty, value);
|
||||
}
|
||||
|
||||
public bool HasConflict
|
||||
{
|
||||
get => (bool)GetValue(HasConflictProperty);
|
||||
set => SetValue(HasConflictProperty, value);
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
e.Handled = true;
|
||||
var key = e.Key == Key.System ? e.SystemKey : e.Key;
|
||||
// 忽略纯修饰键,等待主键
|
||||
if (key is Key.LeftCtrl or Key.RightCtrl or Key.LeftShift or Key.RightShift
|
||||
or Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin)
|
||||
return;
|
||||
HotkeyText = HotkeyFormat.Format(Keyboard.Modifiers, key);
|
||||
Keyboard.ClearFocus();
|
||||
}
|
||||
|
||||
private static void OnVisualChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) =>
|
||||
((HotkeyRecorder)d).UpdateVisuals();
|
||||
|
||||
private void UpdateVisuals()
|
||||
{
|
||||
_text.Text = string.IsNullOrWhiteSpace(HotkeyText) ? "点击录制快捷键" : HotkeyText;
|
||||
_text.FontSize = string.IsNullOrWhiteSpace(HotkeyText) ? 12 : 13;
|
||||
if (HasConflict)
|
||||
_border.SetResourceReference(Border.BorderBrushProperty, "DangerBrush");
|
||||
else
|
||||
_border.SetResourceReference(Border.BorderBrushProperty, "BorderBrush");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<Window x:Class="OneClickRun.Controls.WheelItemEditor"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="编辑指令"
|
||||
Width="540" SizeToContent="Height" ResizeMode="NoResize"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Background="{DynamicResource WindowBackgroundBrush}">
|
||||
<Grid Margin="22">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="90"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="名称" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<TextBox x:Name="NameBox" Grid.Row="0" Grid.Column="1" Margin="0,0,0,12" MaxLength="30"/>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="类型" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<ComboBox x:Name="TypeBox" Grid.Row="1" Grid.Column="1" Margin="0,0,0,12" Height="32">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource EnumDisplay}}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Text="路径 / 网址" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<Grid Grid.Row="2" Grid.Column="1" Margin="0,0,0,12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBox x:Name="PathBox"/>
|
||||
<Button x:Name="BrowseButton" Grid.Column="1" Content="浏览…" Style="{StaticResource SecondaryButton}" Margin="10,0,0,0" Click="Browse_Click"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="参数(可选)" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}" Margin="0,0,0,12"/>
|
||||
<TextBox x:Name="ArgsBox" Grid.Row="3" Grid.Column="1" Margin="0,0,0,12"/>
|
||||
|
||||
<TextBlock x:Name="HintText" Grid.Row="4" Grid.Column="0" Grid.ColumnSpan="2" Style="{StaticResource BodyText}" Margin="0,0,0,16"/>
|
||||
|
||||
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="取消" Style="{StaticResource SecondaryButton}" MinWidth="80" IsCancel="True"/>
|
||||
<Button Content="保存" Style="{StaticResource PrimaryButton}" MinWidth="80" Margin="10,0,0,0" IsDefault="True" Click="Save_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Win32;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Controls;
|
||||
|
||||
/// <summary>轮盘指令编辑对话框</summary>
|
||||
public partial class WheelItemEditor : Window
|
||||
{
|
||||
public WheelItem? Result { get; private set; }
|
||||
|
||||
public WheelItemEditor(WheelItem? existing = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
TypeBox.ItemsSource = Enum.GetValues<WheelItemType>();
|
||||
TypeBox.SelectionChanged += (_, _) => UpdateHint();
|
||||
if (existing != null)
|
||||
{
|
||||
NameBox.Text = existing.Name;
|
||||
TypeBox.SelectedItem = existing.Type;
|
||||
PathBox.Text = existing.Path;
|
||||
ArgsBox.Text = existing.Args;
|
||||
}
|
||||
else
|
||||
{
|
||||
TypeBox.SelectedIndex = 0;
|
||||
}
|
||||
UpdateHint();
|
||||
}
|
||||
|
||||
private void Browse_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
switch ((WheelItemType)(TypeBox.SelectedItem ?? WheelItemType.App))
|
||||
{
|
||||
case WheelItemType.App:
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "选择程序",
|
||||
Filter = "程序 (*.exe;*.lnk;*.bat;*.cmd)|*.exe;*.lnk;*.bat;*.cmd|所有文件 (*.*)|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog(this) == true) SetPath(dialog.FileName);
|
||||
break;
|
||||
}
|
||||
case WheelItemType.Folder:
|
||||
{
|
||||
var dialog = new OpenFolderDialog { Title = "选择文件夹" };
|
||||
if (dialog.ShowDialog(this) == true) SetPath(dialog.FolderName);
|
||||
break;
|
||||
}
|
||||
case WheelItemType.Script:
|
||||
{
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "选择 PowerShell 脚本",
|
||||
Filter = "PowerShell 脚本 (*.ps1)|*.ps1|所有文件 (*.*)|*.*",
|
||||
};
|
||||
if (dialog.ShowDialog(this) == true) SetPath(dialog.FileName);
|
||||
break;
|
||||
}
|
||||
case WheelItemType.Url:
|
||||
// 网址无浏览,直接输入
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPath(string path)
|
||||
{
|
||||
PathBox.Text = path;
|
||||
if (string.IsNullOrWhiteSpace(NameBox.Text) && path.IndexOfAny(Path.GetInvalidPathChars()) < 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
if (!string.IsNullOrWhiteSpace(name)) NameBox.Text = name;
|
||||
}
|
||||
catch { /* 忽略命名推断失败 */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var name = NameBox.Text.Trim();
|
||||
var path = PathBox.Text.Trim();
|
||||
var type = (WheelItemType)(TypeBox.SelectedItem ?? WheelItemType.App);
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
MessageBox.Show(this, "请输入指令名称。", "一键运行", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
if (path.Length == 0)
|
||||
{
|
||||
MessageBox.Show(this, type == WheelItemType.Url ? "请输入网址。" : "请选择路径。", "一键运行",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
if (type == WheelItemType.Url)
|
||||
path = CommandRunner.NormalizeUrl(path);
|
||||
|
||||
Result = new WheelItem
|
||||
{
|
||||
Name = name,
|
||||
Type = type,
|
||||
Path = path,
|
||||
Args = ArgsBox.Text.Trim(),
|
||||
};
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void UpdateHint()
|
||||
{
|
||||
var type = (WheelItemType)(TypeBox.SelectedItem ?? WheelItemType.App);
|
||||
HintText.Text = type switch
|
||||
{
|
||||
WheelItemType.App => "提示:支持 .exe、快捷方式(.lnk)、批处理(.bat/.cmd)等,可用“参数”追加启动参数。",
|
||||
WheelItemType.Folder => "提示:选择后将在资源管理器中打开该文件夹。",
|
||||
WheelItemType.Url => "提示:网址将用默认浏览器打开,无协议前缀时自动补全 https://。",
|
||||
WheelItemType.Script => "提示:脚本由 PowerShell 7(pwsh.exe,建议 7.6.5)执行,可在“参数”中追加脚本参数。",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Data;
|
||||
using System.Windows.Media;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Converters;
|
||||
|
||||
/// <summary>枚举 → 中文显示名</summary>
|
||||
public class EnumToDisplayConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value switch
|
||||
{
|
||||
WheelItemType.App => "打开软件",
|
||||
WheelItemType.Folder => "打开文件夹",
|
||||
WheelItemType.Url => "打开网址",
|
||||
WheelItemType.Script => "运行脚本",
|
||||
TriggerMode.Click => "点击显示",
|
||||
TriggerMode.Hold => "按住显示",
|
||||
TriggerMode.LongPress => "长按显示",
|
||||
SelectionMode.SingleClick => "单击选择",
|
||||
SelectionMode.DoubleClick => "双击选择",
|
||||
SelectionMode.Swipe => "滑动选择",
|
||||
ThemeMode.System => "跟随系统",
|
||||
ThemeMode.Light => "日间模式",
|
||||
ThemeMode.Dark => "黑夜模式",
|
||||
WheelPosition.ScreenCenter => "屏幕中心",
|
||||
WheelPosition.Mouse => "鼠标位置",
|
||||
_ => value?.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
|
||||
/// <summary>枚举值 == 参数 时返回 true(用于单选按钮与控件可用性绑定)</summary>
|
||||
public class EnumEqualsConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
string.Equals(value?.ToString(), parameter?.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is true && targetType.IsEnum && parameter is string name &&
|
||||
Enum.TryParse(targetType, name, true, out var result))
|
||||
return result;
|
||||
return Binding.DoNothing;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>指令类型 → 徽标颜色</summary>
|
||||
public class EnumToBadgeBrushConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var color = value switch
|
||||
{
|
||||
WheelItemType.App => "#4F6BFF",
|
||||
WheelItemType.Folder => "#2FAE9C",
|
||||
WheelItemType.Url => "#E8932F",
|
||||
WheelItemType.Script => "#9A5BE8",
|
||||
_ => "#6B7280",
|
||||
};
|
||||
var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color));
|
||||
brush.Freeze();
|
||||
return brush;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
|
||||
/// <summary>空集合时显示占位文本</summary>
|
||||
public class CountToVisibilityConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var count = value is int i ? i : 0;
|
||||
var visible = count == 0;
|
||||
if (parameter is string p && p == "Invert") visible = !visible;
|
||||
return visible ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
Binding.DoNothing;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using Drawing = System.Drawing;
|
||||
using Drawing2D = System.Drawing.Drawing2D;
|
||||
using SysIcon = System.Drawing.Icon;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>运行时绘制应用图标(托盘 + 窗口)</summary>
|
||||
public static class AppIcon
|
||||
{
|
||||
private static SysIcon? _trayIcon;
|
||||
|
||||
public static SysIcon GetTrayIcon() => _trayIcon ??= Create();
|
||||
|
||||
public static ImageSource GetWindowIcon()
|
||||
{
|
||||
var icon = GetTrayIcon();
|
||||
return Imaging.CreateBitmapSourceFromHIcon(icon.Handle, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
|
||||
}
|
||||
|
||||
private static SysIcon Create()
|
||||
{
|
||||
const int size = 32;
|
||||
using var bitmap = new Drawing.Bitmap(size, size);
|
||||
using (var g = Drawing.Graphics.FromImage(bitmap))
|
||||
{
|
||||
g.SmoothingMode = Drawing2D.SmoothingMode.AntiAlias;
|
||||
var rect = new Drawing.Rectangle(1, 1, size - 2, size - 2);
|
||||
using var background = new Drawing2D.LinearGradientBrush(
|
||||
rect, Drawing.Color.FromArgb(79, 107, 255), Drawing.Color.FromArgb(122, 92, 255), 45f);
|
||||
g.FillEllipse(background, rect);
|
||||
using var pen = new Drawing.Pen(Drawing.Color.White, 3f)
|
||||
{
|
||||
StartCap = Drawing2D.LineCap.Round,
|
||||
EndCap = Drawing2D.LineCap.Round,
|
||||
};
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
var start = i * 45f + 8f;
|
||||
var sweep = 45f - 16f;
|
||||
g.DrawArc(pen, 7, 7, size - 14, size - 14, start, sweep);
|
||||
}
|
||||
using var dot = new Drawing.SolidBrush(Drawing.Color.White);
|
||||
g.FillEllipse(dot, size / 2f - 3f, size / 2f - 3f, 6, 6);
|
||||
}
|
||||
var hicon = bitmap.GetHicon();
|
||||
try
|
||||
{
|
||||
return (SysIcon)SysIcon.FromHandle(hicon).Clone();
|
||||
}
|
||||
finally
|
||||
{
|
||||
DestroyIcon(hicon);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern bool DestroyIcon(IntPtr hIcon);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Windows;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>多显示器 DPI 换算辅助(PerMonitorV2 下物理像素 ↔ WPF 设备无关像素)</summary>
|
||||
public static class DpiUtil
|
||||
{
|
||||
public static double GetScale(Point screenPx)
|
||||
{
|
||||
try
|
||||
{
|
||||
var monitor = Win32Interop.MonitorFromPoint(
|
||||
new Win32Interop.POINT((int)Math.Round(screenPx.X), (int)Math.Round(screenPx.Y)),
|
||||
Win32Interop.MONITOR_DEFAULTTONEAREST);
|
||||
if (monitor != IntPtr.Zero &&
|
||||
Win32Interop.GetDpiForMonitor(monitor, Win32Interop.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0 &&
|
||||
dpiX > 0)
|
||||
return dpiX / 96.0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"获取显示器 DPI 失败: {ex.Message}");
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
public static Point PxToDip(Point px)
|
||||
{
|
||||
var scale = GetScale(px);
|
||||
return new Point(px.X / scale, px.Y / scale);
|
||||
}
|
||||
|
||||
public static Point GetCursorPosDip() =>
|
||||
Win32Interop.TryGetCursorPos(out var px) ? PxToDip(px) : default;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// 全局低级鼠标钩子(WH_MOUSE_LL),仅在轮盘显示期间启用,
|
||||
/// 用于检测"点击轮盘外区域"以隐藏轮盘。不吞掉任何鼠标事件。
|
||||
/// </summary>
|
||||
public sealed class GlobalMouseHook : IDisposable
|
||||
{
|
||||
private static Win32Interop.LowLevelMouseProc? _callback;
|
||||
private static IntPtr _hook;
|
||||
private static GlobalMouseHook? _active;
|
||||
|
||||
public event Action<System.Windows.Point>? LeftButtonDown;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_hook != IntPtr.Zero) return;
|
||||
_active = this;
|
||||
_callback = HookCallback;
|
||||
_hook = Win32Interop.SetWindowsHookEx(
|
||||
Win32Interop.WH_MOUSE_LL, _callback, Win32Interop.GetModuleHandle(null), 0);
|
||||
if (_hook == IntPtr.Zero)
|
||||
Logger.Warn("全局鼠标钩子安装失败,点击轮盘外区域将无法自动隐藏");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (_hook == IntPtr.Zero) return;
|
||||
Win32Interop.UnhookWindowsHookEx(_hook);
|
||||
_hook = IntPtr.Zero;
|
||||
_callback = null;
|
||||
_active = null;
|
||||
}
|
||||
|
||||
private static IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
|
||||
{
|
||||
if (nCode >= 0 && wParam.ToInt64() == Win32Interop.WM_LBUTTONDOWN)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = Marshal.PtrToStructure<Win32Interop.MSLLHOOKSTRUCT>(lParam);
|
||||
_active?.LeftButtonDown?.Invoke(new System.Windows.Point(data.pt.X, data.pt.Y));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 钩子回调内不允许抛异常
|
||||
}
|
||||
}
|
||||
return Win32Interop.CallNextHookEx(_hook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Windows.Input;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>快捷键与文本(如 "Ctrl+Alt+Space")之间的转换</summary>
|
||||
public static class HotkeyFormat
|
||||
{
|
||||
public static string Format(HotkeyBinding? binding)
|
||||
{
|
||||
if (binding == null || !binding.IsValid) return string.Empty;
|
||||
var parts = new List<string>();
|
||||
foreach (var m in new[] { "Ctrl", "Alt", "Shift", "Win" })
|
||||
if (binding.Modifiers.Contains(m)) parts.Add(m);
|
||||
parts.Add(binding.Key);
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
public static string Format(ModifierKeys mods, Key key)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (mods.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl");
|
||||
if (mods.HasFlag(ModifierKeys.Alt)) parts.Add("Alt");
|
||||
if (mods.HasFlag(ModifierKeys.Shift)) parts.Add("Shift");
|
||||
if (mods.HasFlag(ModifierKeys.Windows)) parts.Add("Win");
|
||||
parts.Add(key.ToString());
|
||||
return string.Join("+", parts);
|
||||
}
|
||||
|
||||
/// <summary>解析快捷键文本;空文本返回空绑定;非法文本返回 null</summary>
|
||||
public static HotkeyBinding? TryParse(string? text)
|
||||
{
|
||||
text = text?.Trim();
|
||||
if (string.IsNullOrEmpty(text)) return new HotkeyBinding();
|
||||
var tokens = text.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (tokens.Length == 0) return null;
|
||||
var keyToken = tokens[^1];
|
||||
if (!Enum.TryParse<Key>(keyToken, true, out var key) || key == Key.None) return null;
|
||||
var mods = new List<string>();
|
||||
foreach (var t in tokens[..^1])
|
||||
{
|
||||
switch (t.ToLowerInvariant())
|
||||
{
|
||||
case "ctrl": case "control": mods.Add("Ctrl"); break;
|
||||
case "alt": mods.Add("Alt"); break;
|
||||
case "shift": mods.Add("Shift"); break;
|
||||
case "win": case "windows": mods.Add("Win"); break;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
return new HotkeyBinding { Modifiers = mods.Distinct().ToList(), Key = key.ToString() };
|
||||
}
|
||||
|
||||
public static Key ParseKey(string keyName) =>
|
||||
Enum.TryParse<Key>(keyName, true, out var key) ? key : Key.None;
|
||||
|
||||
public static int VirtualKeyOf(string keyName) => KeyInterop.VirtualKeyFromKey(ParseKey(keyName));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// 轮盘扇形几何。N 个扇形等角分布;为保证"不论距离中心多远,扇形之间的间距都相等",
|
||||
/// 每个扇形的两条径向边沿垂直方向向内偏移 gap/2:边界射线 θ 在半径 ρ 处的偏移角为
|
||||
/// ε(ρ) = asin(gap / (2ρ)),因此相邻扇形的相邻边是两条相距恒为 gap 的平行线。
|
||||
/// </summary>
|
||||
public static class WheelGeometry
|
||||
{
|
||||
public const double TwoPi = Math.PI * 2;
|
||||
/// <summary>起始角度:12 点钟方向(屏幕坐标 y 向下,角度沿顺时针为正)</summary>
|
||||
public const double StartAngle = -Math.PI / 2;
|
||||
|
||||
public static Point PointOnCircle(Point center, double radius, double angleRad) =>
|
||||
new(center.X + radius * Math.Cos(angleRad), center.Y + radius * Math.Sin(angleRad));
|
||||
|
||||
/// <summary>半径 radius 处,间隙 gap 对应的边界偏移角</summary>
|
||||
public static double EdgeOffset(double radius, double gap)
|
||||
{
|
||||
if (radius <= 0 || gap <= 0) return 0;
|
||||
var v = Math.Clamp(gap / (2 * radius), -1.0, 1.0);
|
||||
return Math.Asin(v);
|
||||
}
|
||||
|
||||
public static double SectorStartAngle(int index, int count) => StartAngle + index * TwoPi / count;
|
||||
|
||||
/// <summary>扇形四个角点:内左、外左、外右、内右(逆时针定义,屏幕坐标)</summary>
|
||||
public static (Point innerLeft, Point outerLeft, Point outerRight, Point innerRight) SectorCorners(
|
||||
Point center, double innerR, double outerR, double gap, int index, int count)
|
||||
{
|
||||
var a = SectorStartAngle(index, count);
|
||||
var b = a + TwoPi / count;
|
||||
var eo = EdgeOffset(outerR, gap);
|
||||
var ei = EdgeOffset(innerR, gap);
|
||||
return (
|
||||
PointOnCircle(center, innerR, a + ei),
|
||||
PointOnCircle(center, outerR, a + eo),
|
||||
PointOnCircle(center, outerR, b - eo),
|
||||
PointOnCircle(center, innerR, b - ei));
|
||||
}
|
||||
|
||||
/// <summary>生成单个扇形的 Path 几何(count==1 时为完整圆环)</summary>
|
||||
public static Geometry CreateSectorGeometry(Point center, double innerR, double outerR, double gap, int index, int count)
|
||||
{
|
||||
if (count <= 1)
|
||||
{
|
||||
var outer = new EllipseGeometry(center, outerR, outerR);
|
||||
var inner = new EllipseGeometry(center, innerR, innerR);
|
||||
var combined = new CombinedGeometry(GeometryCombineMode.Exclude, outer, inner);
|
||||
combined.Freeze();
|
||||
return combined;
|
||||
}
|
||||
var c = SectorCorners(center, innerR, outerR, gap, index, count);
|
||||
var geometry = new StreamGeometry();
|
||||
using (var ctx = geometry.Open())
|
||||
{
|
||||
ctx.BeginFigure(c.innerLeft, isFilled: true, isClosed: true);
|
||||
ctx.LineTo(c.outerLeft, true, false);
|
||||
ctx.ArcTo(c.outerRight, new Size(outerR, outerR), 0, false, SweepDirection.Clockwise, true, false);
|
||||
ctx.LineTo(c.innerRight, true, false);
|
||||
ctx.ArcTo(c.innerLeft, new Size(innerR, innerR), 0, false, SweepDirection.Counterclockwise, true, false);
|
||||
}
|
||||
geometry.Freeze();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/// <summary>扇形文本/图标放置点(半径中点、角度平分线)</summary>
|
||||
public static Point SectorMidPoint(Point center, double innerR, double outerR, int index, int count)
|
||||
{
|
||||
var mid = SectorStartAngle(index, count) + TwoPi / count / 2;
|
||||
return PointOnCircle(center, (innerR + outerR) / 2, mid);
|
||||
}
|
||||
|
||||
/// <summary>扇形中轴角度(度,屏幕坐标,用于文本旋转)</summary>
|
||||
public static double SectorMidAngleDeg(int index, int count) =>
|
||||
(SectorStartAngle(index, count) + TwoPi / count / 2) * 180 / Math.PI;
|
||||
|
||||
/// <summary>
|
||||
/// 命中测试:返回点所在扇形索引;中心区域、圆环外或间隙处返回 null。
|
||||
/// </summary>
|
||||
public static int? HitTest(Point point, Point center, double innerR, double outerR, double gap, int count)
|
||||
{
|
||||
var dx = point.X - center.X;
|
||||
var dy = point.Y - center.Y;
|
||||
var rho = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (rho < innerR || rho > outerR) return null;
|
||||
if (count <= 1) return 0;
|
||||
|
||||
var phi = Math.Atan2(dy, dx);
|
||||
var rel = ((phi - StartAngle) % TwoPi + TwoPi) % TwoPi;
|
||||
var span = TwoPi / count;
|
||||
var index = (int)(rel / span);
|
||||
if (index >= count) index = count - 1;
|
||||
|
||||
var eps = EdgeOffset(rho, gap);
|
||||
var fromLeft = rel - index * span;
|
||||
var fromRight = (index + 1) * span - rel;
|
||||
if (fromLeft < eps || fromRight < eps) return null; // 落在间隙
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace OneClickRun.Helpers;
|
||||
|
||||
/// <summary>Win32 P/Invoke 互操作集合</summary>
|
||||
public static class Win32Interop
|
||||
{
|
||||
public const int WM_HOTKEY = 0x0312;
|
||||
public const int WM_LBUTTONDOWN = 0x0201;
|
||||
public const int WH_MOUSE_LL = 14;
|
||||
|
||||
public const uint MOD_ALT = 0x0001;
|
||||
public const uint MOD_CONTROL = 0x0002;
|
||||
public const uint MOD_SHIFT = 0x0004;
|
||||
public const uint MOD_WIN = 0x0008;
|
||||
public const uint MOD_NOREPEAT = 0x4000;
|
||||
|
||||
public const int VK_ESCAPE = 0x1B;
|
||||
public const int VK_SHIFT = 0x10;
|
||||
public const int VK_CONTROL = 0x11;
|
||||
public const int VK_MENU = 0x12;
|
||||
public const int VK_LWIN = 0x5B;
|
||||
public const int VK_RWIN = 0x5C;
|
||||
|
||||
public const int MONITOR_DEFAULTTONEAREST = 2;
|
||||
public const int MDT_EFFECTIVE_DPI = 0;
|
||||
|
||||
public static readonly IntPtr HWND_BROADCAST = new(0xffff);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
public POINT(int x, int y) { X = x; Y = y; }
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MSLLHOOKSTRUCT
|
||||
{
|
||||
public POINT pt;
|
||||
public uint mouseData;
|
||||
public uint flags;
|
||||
public uint time;
|
||||
public IntPtr dwExtraInfo;
|
||||
}
|
||||
|
||||
public delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool RegisterHotKey(IntPtr hWnd, int id, uint fsModifiers, uint vk);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
public static extern bool UnregisterHotKey(IntPtr hWnd, int id);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vKey);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelMouseProc lpfn, IntPtr hMod, uint dwThreadId);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool UnhookWindowsHookEx(IntPtr hhk);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern IntPtr GetModuleHandle(string? lpModuleName);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
||||
|
||||
[DllImport("Shcore.dll")]
|
||||
public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern uint RegisterWindowMessage(string lpString);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
public static bool IsKeyDown(int vk) => (GetAsyncKeyState(vk) & 0x8000) != 0;
|
||||
|
||||
public static uint ModifiersToWin32(IEnumerable<string> modifiers)
|
||||
{
|
||||
uint flags = 0;
|
||||
foreach (var m in modifiers)
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case "Ctrl": flags |= MOD_CONTROL; break;
|
||||
case "Alt": flags |= MOD_ALT; break;
|
||||
case "Shift": flags |= MOD_SHIFT; break;
|
||||
case "Win": flags |= MOD_WIN; break;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
public static bool AreComboModifiersDown(IEnumerable<string> modifiers)
|
||||
{
|
||||
foreach (var m in modifiers)
|
||||
{
|
||||
switch (m)
|
||||
{
|
||||
case "Ctrl": if (!IsKeyDown(VK_CONTROL)) return false; break;
|
||||
case "Alt": if (!IsKeyDown(VK_MENU)) return false; break;
|
||||
case "Shift": if (!IsKeyDown(VK_SHIFT)) return false; break;
|
||||
case "Win": if (!IsKeyDown(VK_LWIN) && !IsKeyDown(VK_RWIN)) return false; break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryGetCursorPos(out System.Windows.Point point)
|
||||
{
|
||||
if (GetCursorPos(out POINT p))
|
||||
{
|
||||
point = new System.Windows.Point(p.X, p.Y);
|
||||
return true;
|
||||
}
|
||||
point = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>应用配置根对象(持久化到 config.json)</summary>
|
||||
public class AppConfig
|
||||
{
|
||||
public int Version { get; set; } = 1;
|
||||
public GeneralSettings General { get; set; } = new();
|
||||
public List<WheelItem> Items { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>轮盘指令类型</summary>
|
||||
public enum WheelItemType { App, Folder, Url, Script }
|
||||
|
||||
/// <summary>主题模式</summary>
|
||||
public enum ThemeMode { System, Light, Dark }
|
||||
|
||||
/// <summary>轮盘显示位置</summary>
|
||||
public enum WheelPosition { ScreenCenter, Mouse }
|
||||
|
||||
/// <summary>呼出轮盘方式</summary>
|
||||
public enum TriggerMode { Click, Hold, LongPress }
|
||||
|
||||
/// <summary>指令选择方式</summary>
|
||||
public enum SelectionMode { SingleClick, DoubleClick, Swipe }
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OneClickRun.Helpers;
|
||||
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>系统设置(实现 INotifyPropertyChanged 以支持界面即时生效)</summary>
|
||||
public class GeneralSettings : INotifyPropertyChanged
|
||||
{
|
||||
private bool _autoStart;
|
||||
private bool _wheelEnabled = true;
|
||||
private WheelPosition _wheelPosition = WheelPosition.ScreenCenter;
|
||||
private HotkeyBinding _hotkey = new() { Modifiers = { "Ctrl", "Alt" }, Key = "Space" };
|
||||
private TriggerMode _triggerMode = TriggerMode.Click;
|
||||
private int _longPressMs = 400;
|
||||
private SelectionMode _selectionMode = SelectionMode.SingleClick;
|
||||
private ThemeMode _theme = ThemeMode.System;
|
||||
|
||||
/// <summary>开机启动</summary>
|
||||
public bool AutoStart { get => _autoStart; set => Set(ref _autoStart, value); }
|
||||
|
||||
/// <summary>轮盘全局开关</summary>
|
||||
public bool WheelEnabled { get => _wheelEnabled; set => Set(ref _wheelEnabled, value); }
|
||||
|
||||
/// <summary>轮盘显示位置</summary>
|
||||
public WheelPosition WheelPosition { get => _wheelPosition; set => Set(ref _wheelPosition, value); }
|
||||
|
||||
/// <summary>呼出轮盘的全局快捷键</summary>
|
||||
public HotkeyBinding Hotkey { get => _hotkey; set => Set(ref _hotkey, value ?? new HotkeyBinding()); }
|
||||
|
||||
/// <summary>呼出方式:点击 / 长按 / 按住</summary>
|
||||
public TriggerMode TriggerMode
|
||||
{
|
||||
get => _triggerMode;
|
||||
set
|
||||
{
|
||||
if (Set(ref _triggerMode, value) && value != TriggerMode.Hold && SelectionMode == SelectionMode.Swipe)
|
||||
SelectionMode = SelectionMode.SingleClick; // 滑动选择仅按住显示下有效
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>长按呼出阈值(毫秒)</summary>
|
||||
public int LongPressMs { get => _longPressMs; set => Set(ref _longPressMs, Math.Clamp(value, 300, 1500)); }
|
||||
|
||||
/// <summary>选择方式:单击 / 双击 / 滑动</summary>
|
||||
public SelectionMode SelectionMode { get => _selectionMode; set => Set(ref _selectionMode, value); }
|
||||
|
||||
/// <summary>主题:日间 / 黑夜 / 跟随系统</summary>
|
||||
public ThemeMode Theme { get => _theme; set => Set(ref _theme, value); }
|
||||
|
||||
/// <summary>快捷键文本(供录制控件绑定)</summary>
|
||||
public string HotkeyText
|
||||
{
|
||||
get => HotkeyFormat.Format(Hotkey);
|
||||
set => Hotkey = HotkeyFormat.TryParse(value) ?? new HotkeyBinding();
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>全局快捷键:修饰键 + 主键(主键使用 WPF Key 名称,如 "Space"、"F8")</summary>
|
||||
public class HotkeyBinding
|
||||
{
|
||||
public List<string> Modifiers { get; set; } = new();
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
public bool IsValid => !string.IsNullOrWhiteSpace(Key);
|
||||
|
||||
public HotkeyBinding Clone() => new() { Modifiers = new List<string>(Modifiers), Key = Key };
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace OneClickRun.Models;
|
||||
|
||||
/// <summary>一条轮盘指令</summary>
|
||||
public class WheelItem
|
||||
{
|
||||
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public WheelItemType Type { get; set; } = WheelItemType.App;
|
||||
public string Path { get; set; } = string.Empty;
|
||||
public string Args { get; set; } = string.Empty;
|
||||
|
||||
public WheelItem Clone() => new()
|
||||
{
|
||||
Id = Id,
|
||||
Name = Name,
|
||||
Type = Type,
|
||||
Path = Path,
|
||||
Args = Args,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OneClickRun</RootNamespace>
|
||||
<AssemblyName>OneClickRun</AssemblyName>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<Version>1.0.0</Version>
|
||||
<Product>一键运行</Product>
|
||||
<AssemblyTitle>一键运行</AssemblyTitle>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="System" />
|
||||
<Using Include="System.Collections.Generic" />
|
||||
<Using Include="System.IO" />
|
||||
<Using Include="System.Linq" />
|
||||
<Using Include="System.Threading" />
|
||||
<Using Include="System.Threading.Tasks" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="assets\samples\hello.ps1">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly: ThemeInfo(ResourceDictionaryLocation.None, ResourceDictionaryLocation.SourceAssembly)]
|
||||
@@ -0,0 +1,49 @@
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>开机启动(HKCU Run 键,无需管理员)</summary>
|
||||
public class AutostartService
|
||||
{
|
||||
private const string RunKeyPath = @"SoftwareMicrosoftWindowsCurrentVersionRun";
|
||||
private const string ValueName = "OneClickRun";
|
||||
|
||||
public bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath);
|
||||
return key?.GetValue(ValueName) is string value && value.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("读取开机启动状态失败", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool SetEnabled(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath);
|
||||
if (enabled)
|
||||
{
|
||||
var exe = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(exe)) return false;
|
||||
key.SetValue(ValueName, $"\"{exe}\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||
}
|
||||
Logger.Info($"开机启动已{(enabled ? "开启" : "关闭")}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("设置开机启动失败", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
public readonly record struct CommandResult(bool Success, string Message)
|
||||
{
|
||||
public static CommandResult Ok() => new(true, string.Empty);
|
||||
public static CommandResult Fail(string message) => new(false, message);
|
||||
}
|
||||
|
||||
/// <summary>执行四类指令:打开软件 / 文件夹 / 网址 / PowerShell 脚本</summary>
|
||||
public class CommandRunner
|
||||
{
|
||||
public async Task<CommandResult> ExecuteAsync(WheelItem item)
|
||||
{
|
||||
try
|
||||
{
|
||||
return item.Type switch
|
||||
{
|
||||
WheelItemType.App => await RunAppAsync(item),
|
||||
WheelItemType.Folder => RunFolder(item.Path),
|
||||
WheelItemType.Url => RunUrl(item.Path),
|
||||
WheelItemType.Script => await RunScriptAsync(item),
|
||||
_ => CommandResult.Fail($"未知指令类型: {item.Type}"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"执行指令失败 [{item.Name}] ({item.Type}: {item.Path})", ex);
|
||||
return CommandResult.Fail($"执行「{item.Name}」失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<CommandResult> RunAppAsync(WheelItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Path) || !File.Exists(item.Path))
|
||||
return Task.FromResult(CommandResult.Fail($"程序不存在:{item.Path}"));
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = item.Path,
|
||||
Arguments = item.Args ?? string.Empty,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
Logger.Info($"启动软件: {item.Path}");
|
||||
return Task.FromResult(CommandResult.Ok());
|
||||
}
|
||||
catch (Win32Exception ex)
|
||||
{
|
||||
// 常见于用户在 UAC 提示中取消、或系统找不到关联程序
|
||||
Logger.Warn($"启动软件被取消或失败: {item.Path} ({ex.Message})");
|
||||
return Task.FromResult(CommandResult.Fail($"启动「{item.Name}」失败:{ex.Message}"));
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult RunFolder(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path))
|
||||
return CommandResult.Fail($"文件夹不存在:{path}");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = $"\"{path}\"",
|
||||
UseShellExecute = false,
|
||||
});
|
||||
Logger.Info($"打开文件夹: {path}");
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"打开文件夹失败: {path}", ex);
|
||||
return CommandResult.Fail($"打开文件夹失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static CommandResult RunUrl(string rawUrl)
|
||||
{
|
||||
var url = NormalizeUrl(rawUrl);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return CommandResult.Fail("网址为空");
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
|
||||
Logger.Info($"打开网址: {url}");
|
||||
return CommandResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"打开网址失败: {url}", ex);
|
||||
return CommandResult.Fail($"打开网址失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<CommandResult> RunScriptAsync(WheelItem item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Path) || !File.Exists(item.Path))
|
||||
return CommandResult.Fail($"脚本不存在:{item.Path}");
|
||||
|
||||
var pwsh = PowerShellLocator.Find();
|
||||
if (pwsh == null)
|
||||
return CommandResult.Fail("未找到 PowerShell 7(pwsh.exe)。请安装 PowerShell 7.6.5 后重试。");
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = pwsh,
|
||||
Arguments = PowerShellLocator.BuildScriptArguments(item.Path, item.Args),
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetDirectoryName(Path.GetFullPath(item.Path)) ?? string.Empty,
|
||||
CreateNoWindow = false,
|
||||
};
|
||||
Logger.Info($"运行脚本: {pwsh} -File \"{item.Path}\"");
|
||||
using var process = Process.Start(startInfo);
|
||||
if (process == null) return CommandResult.Fail("脚本进程启动失败");
|
||||
await process.WaitForExitAsync();
|
||||
var code = process.ExitCode;
|
||||
if (code == 0) return CommandResult.Ok();
|
||||
Logger.Warn($"脚本退出码 {code}: {item.Path}");
|
||||
return CommandResult.Fail($"脚本「{item.Name}」执行完毕,退出码 {code}(非 0),请检查脚本输出。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error($"运行脚本失败: {item.Path}", ex);
|
||||
return CommandResult.Fail($"运行脚本失败:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static string NormalizeUrl(string rawUrl)
|
||||
{
|
||||
var url = (rawUrl ?? string.Empty).Trim();
|
||||
if (url.Length == 0) return url;
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
|
||||
(uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||||
return uri.ToString();
|
||||
if (url.Contains("://")) return url; // 其他协议原样保留
|
||||
return "https://" + url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>配置读写:%APPDATA%\OneClickRun\config.json,原子写入,损坏自动备份重建</summary>
|
||||
public class ConfigService
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
private readonly object _lock = new();
|
||||
|
||||
public string ConfigDirectory { get; }
|
||||
public string ConfigPath => Path.Combine(ConfigDirectory, "config.json");
|
||||
public AppConfig Current { get; private set; } = new();
|
||||
public bool WasFirstRun { get; private set; }
|
||||
|
||||
public ConfigService(string? directory = null)
|
||||
{
|
||||
ConfigDirectory = directory ??
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun");
|
||||
}
|
||||
|
||||
public void Load()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try { Directory.CreateDirectory(ConfigDirectory); } catch { }
|
||||
if (!File.Exists(ConfigPath))
|
||||
{
|
||||
Current = CreateDefault();
|
||||
WasFirstRun = true;
|
||||
Save();
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var cfg = JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(ConfigPath), Options);
|
||||
Current = Repair(cfg);
|
||||
if (cfg == null) BackupBroken("null");
|
||||
WasFirstRun = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("配置文件损坏,已备份并重建默认配置", ex);
|
||||
BackupBroken(ex.GetType().Name);
|
||||
Current = CreateDefault();
|
||||
WasFirstRun = true;
|
||||
Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ConfigDirectory);
|
||||
var tmp = ConfigPath + ".tmp";
|
||||
File.WriteAllText(tmp, JsonSerializer.Serialize(Current, Options));
|
||||
File.Move(tmp, ConfigPath, overwrite: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error("保存配置失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BackupBroken(string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigPath))
|
||||
File.Copy(ConfigPath,
|
||||
Path.Combine(ConfigDirectory, $"config.broken-{DateTime.Now:yyyyMMdd-HHmmss}-{reason}.json"),
|
||||
overwrite: true);
|
||||
}
|
||||
catch { /* 备份失败忽略 */ }
|
||||
}
|
||||
|
||||
private static AppConfig Repair(AppConfig? cfg)
|
||||
{
|
||||
cfg ??= new AppConfig();
|
||||
cfg.Version = 1;
|
||||
cfg.General ??= new GeneralSettings();
|
||||
cfg.General.Hotkey ??= new HotkeyBinding();
|
||||
cfg.General.LongPressMs = Math.Clamp(cfg.General.LongPressMs, 300, 1500);
|
||||
cfg.Items ??= new List<WheelItem>();
|
||||
foreach (var item in cfg.Items)
|
||||
{
|
||||
item.Name ??= string.Empty;
|
||||
item.Path ??= string.Empty;
|
||||
item.Args ??= string.Empty;
|
||||
if (string.IsNullOrEmpty(item.Id)) item.Id = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/// <summary>默认配置:4 条示例指令(文档文件夹 / 记事本 / 必应 / 示例脚本)</summary>
|
||||
public static AppConfig CreateDefault()
|
||||
{
|
||||
return new AppConfig
|
||||
{
|
||||
General = new GeneralSettings(),
|
||||
Items = new List<WheelItem>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Name = "打开文档",
|
||||
Type = WheelItemType.Folder,
|
||||
Path = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
|
||||
},
|
||||
new()
|
||||
{
|
||||
Name = "记事本",
|
||||
Type = WheelItemType.App,
|
||||
Path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "notepad.exe"),
|
||||
},
|
||||
new() { Name = "必应", Type = WheelItemType.Url, Path = "https://www.bing.com" },
|
||||
new()
|
||||
{
|
||||
Name = "示例脚本",
|
||||
Type = WheelItemType.Script,
|
||||
Path = Path.Combine(AppContext.BaseDirectory, "assets", "samples", "hello.ps1"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Interop;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>全局快捷键服务:RegisterHotKey + WM_HOTKEY 消息挂接</summary>
|
||||
public class HotkeyService : IDisposable
|
||||
{
|
||||
private const int HotkeyId = 0x0C0D;
|
||||
|
||||
private HwndSource? _source;
|
||||
private bool _registered;
|
||||
|
||||
public HotkeyBinding? Current { get; private set; }
|
||||
public bool IsRegistered => _registered;
|
||||
|
||||
/// <summary>快捷键按下(MOD_NOREPEAT,一次按压仅触发一次)</summary>
|
||||
public event Action? HotkeyPressed;
|
||||
|
||||
/// <summary>注册失败(可能与其他程序冲突)</summary>
|
||||
public event Action? RegistrationFailed;
|
||||
|
||||
public void Attach(Window host)
|
||||
{
|
||||
if (_source != null) return;
|
||||
var hwnd = new WindowInteropHelper(host).Handle; // 强制创建句柄
|
||||
_source = HwndSource.FromHwnd(hwnd);
|
||||
_source?.AddHook(WndProc);
|
||||
}
|
||||
|
||||
public void Update(HotkeyBinding? binding)
|
||||
{
|
||||
Unregister();
|
||||
Current = binding?.IsValid == true ? binding : null;
|
||||
if (Current == null || _source == null) return;
|
||||
|
||||
var vk = (uint)HotkeyFormat.VirtualKeyOf(Current.Key);
|
||||
if (vk == 0) return;
|
||||
var mods = Win32Interop.ModifiersToWin32(Current.Modifiers) | Win32Interop.MOD_NOREPEAT;
|
||||
_registered = Win32Interop.RegisterHotKey(_source.Handle, HotkeyId, mods, vk);
|
||||
if (_registered)
|
||||
Logger.Info($"全局快捷键注册成功: {HotkeyFormat.Format(Current)}");
|
||||
else
|
||||
{
|
||||
Logger.Warn($"全局快捷键注册失败: {HotkeyFormat.Format(Current)}");
|
||||
RegistrationFailed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
if (_registered && _source != null)
|
||||
{
|
||||
Win32Interop.UnregisterHotKey(_source.Handle, HotkeyId);
|
||||
_registered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
{
|
||||
if (msg == Win32Interop.WM_HOTKEY && wParam.ToInt64() == HotkeyId)
|
||||
{
|
||||
HotkeyPressed?.Invoke();
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
/// <summary>判断快捷键组合(主键 + 所有修饰键)当前是否处于按下状态</summary>
|
||||
public static bool IsComboDown(HotkeyBinding? binding)
|
||||
{
|
||||
if (binding == null || !binding.IsValid) return false;
|
||||
if (!Win32Interop.IsKeyDown(HotkeyFormat.VirtualKeyOf(binding.Key))) return false;
|
||||
return Win32Interop.AreComboModifiersDown(binding.Modifiers);
|
||||
}
|
||||
|
||||
public void Dispose() => Unregister();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>轻量文件日志(按天滚动,位于 %APPDATA%\OneClickRun\logs)</summary>
|
||||
public static class Logger
|
||||
{
|
||||
private static readonly object Lock = new();
|
||||
private static readonly string Dir =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "OneClickRun", "logs");
|
||||
|
||||
public static string LogDirectory => Dir;
|
||||
|
||||
public static void Init()
|
||||
{
|
||||
try { Directory.CreateDirectory(Dir); } catch { /* 日志目录创建失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
public static void Info(string message) => Write("INFO", message);
|
||||
public static void Warn(string message) => Write("WARN", message);
|
||||
public static void Error(string message, Exception? ex = null) =>
|
||||
Write("ERROR", ex == null ? message : message + " | " + ex);
|
||||
|
||||
private static void Write(string level, string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
Directory.CreateDirectory(Dir);
|
||||
var file = Path.Combine(Dir, $"app-{DateTime.Now:yyyyMMdd}.log");
|
||||
File.AppendAllText(file,
|
||||
$"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] {message}{Environment.NewLine}",
|
||||
Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch { /* 日志失败不抛出 */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>定位 PowerShell 7(pwsh.exe)并构造脚本启动参数</summary>
|
||||
public static class PowerShellLocator
|
||||
{
|
||||
/// <summary>依次从 PATH、标准安装目录查找 pwsh.exe</summary>
|
||||
public static string? Find()
|
||||
{
|
||||
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in pathVar.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(dir.Trim(), "pwsh.exe");
|
||||
if (File.Exists(candidate)) return candidate;
|
||||
}
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string[] candidates =
|
||||
{
|
||||
Path.Combine(programFiles, "PowerShell", "7", "pwsh.exe"),
|
||||
Path.Combine(localAppData, "Programs", "PowerShell", "7", "pwsh.exe"),
|
||||
};
|
||||
foreach (var c in candidates)
|
||||
if (File.Exists(c)) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? GetVersion(string? pwshPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pwshPath) || !File.Exists(pwshPath)) return null;
|
||||
try
|
||||
{
|
||||
using var process = Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = pwshPath,
|
||||
Arguments = "-NoProfile -Command \"$PSVersionTable.PSVersion.ToString()\"",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
});
|
||||
if (process == null) return null;
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
return string.IsNullOrWhiteSpace(output) ? null : output.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Warn($"获取 PowerShell 版本失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>构造 pwsh 启动参数(脚本路径加引号,兼容含空格路径)</summary>
|
||||
public static string BuildScriptArguments(string scriptPath, string? extraArgs)
|
||||
{
|
||||
var quoted = "\"" + scriptPath + "\"";
|
||||
return string.IsNullOrWhiteSpace(extraArgs)
|
||||
? $"-NoProfile -ExecutionPolicy Bypass -File {quoted}"
|
||||
: $"-NoProfile -ExecutionPolicy Bypass -File {quoted} {extraArgs.Trim()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>日间/黑夜/跟随系统主题切换(切换合并的主题资源字典)</summary>
|
||||
public class ThemeService
|
||||
{
|
||||
private const string LightUri = "Themes/Light.xaml";
|
||||
private const string DarkUri = "Themes/Dark.xaml";
|
||||
|
||||
private ThemeMode _mode = ThemeMode.System;
|
||||
private bool _systemEventsHooked;
|
||||
|
||||
public ThemeMode Mode => _mode;
|
||||
|
||||
public void Apply(ThemeMode mode)
|
||||
{
|
||||
_mode = mode;
|
||||
var dark = mode switch
|
||||
{
|
||||
ThemeMode.Light => false,
|
||||
ThemeMode.Dark => true,
|
||||
_ => SystemUsesDark(),
|
||||
};
|
||||
|
||||
var app = Application.Current;
|
||||
var dictionaries = app.Resources.MergedDictionaries;
|
||||
for (var i = dictionaries.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var source = dictionaries[i].Source;
|
||||
if (source != null &&
|
||||
(source.OriginalString == LightUri || source.OriginalString == DarkUri))
|
||||
dictionaries.RemoveAt(i);
|
||||
}
|
||||
dictionaries.Add(new ResourceDictionary { Source = new Uri(dark ? DarkUri : LightUri, UriKind.Relative) });
|
||||
Logger.Info($"主题已应用: {mode} → {(dark ? "黑夜" : "日间")}");
|
||||
|
||||
HookSystemEvents();
|
||||
}
|
||||
|
||||
private void HookSystemEvents()
|
||||
{
|
||||
if (_systemEventsHooked) return;
|
||||
_systemEventsHooked = true;
|
||||
SystemEvents.UserPreferenceChanged += (_, _) =>
|
||||
{
|
||||
if (_mode != ThemeMode.System) return;
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
dispatcher?.BeginInvoke(() => Apply(ThemeMode.System));
|
||||
};
|
||||
}
|
||||
|
||||
public static bool SystemUsesDark()
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = Registry.GetValue(
|
||||
@"HKEY_CURRENT_USERSoftwareMicrosoftWindowsCurrentVersionThemesPersonalize",
|
||||
"AppsUseLightTheme", 1);
|
||||
return value is int i && i == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Windows;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>非阻塞的右下角提示(线程安全,自动切回 UI 线程)</summary>
|
||||
public static class ToastService
|
||||
{
|
||||
private static ToastWindow? _window;
|
||||
|
||||
public static void Show(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
var dispatcher = Application.Current?.Dispatcher;
|
||||
if (dispatcher == null) return;
|
||||
if (!dispatcher.CheckAccess())
|
||||
{
|
||||
dispatcher.BeginInvoke(() => Show(message));
|
||||
return;
|
||||
}
|
||||
if (_window == null)
|
||||
{
|
||||
_window = new ToastWindow();
|
||||
_window.Show();
|
||||
}
|
||||
_window.ShowMessage(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using WinForms = System.Windows.Forms;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.Views;
|
||||
|
||||
namespace OneClickRun.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 轮盘窗口管理器:三种呼出模式(点击/长按/按住)与三种选择模式(单击/双击/滑动)的状态机。
|
||||
/// 通过 30ms 轮询 GetAsyncKeyState 判断快捷键松开;长按达到阈值后才显示轮盘。
|
||||
/// </summary>
|
||||
public class WheelWindowManager : IDisposable
|
||||
{
|
||||
private readonly CommandRunner _runner;
|
||||
private readonly Func<AppConfig> _config;
|
||||
private readonly RadialMenuWindow _window = new();
|
||||
private readonly DispatcherTimer _timer;
|
||||
private readonly GlobalMouseHook _mouseHook = new();
|
||||
|
||||
private bool _shown;
|
||||
private bool _engaged;
|
||||
private bool _longPressFired;
|
||||
private DateTime _engagedAt;
|
||||
|
||||
/// <summary>轮盘全局开关(关闭时快捷键不再呼出)</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>内部调试:快照导出时访问轮盘窗口</summary>
|
||||
internal RadialMenuWindow Window => _window;
|
||||
|
||||
public WheelWindowManager(CommandRunner runner, Func<AppConfig> config)
|
||||
{
|
||||
_runner = runner;
|
||||
_config = config;
|
||||
_window.SectorPicked += OnSectorPicked;
|
||||
_timer = new DispatcherTimer(DispatcherPriority.Normal) { Interval = TimeSpan.FromMilliseconds(30) };
|
||||
_timer.Tick += OnTick;
|
||||
_mouseHook.LeftButtonDown += OnGlobalLeftButtonDown;
|
||||
}
|
||||
|
||||
/// <summary>全局快捷键按下(WM_HOTKEY,MOD_NOREPEAT 保证单次触发)</summary>
|
||||
public void OnHotkeyPressed()
|
||||
{
|
||||
var cfg = _config();
|
||||
if (!Enabled || !cfg.General.WheelEnabled) return;
|
||||
|
||||
switch (cfg.General.TriggerMode)
|
||||
{
|
||||
case TriggerMode.Click:
|
||||
if (_shown) Hide();
|
||||
else Show();
|
||||
break;
|
||||
case TriggerMode.Hold:
|
||||
_engaged = true;
|
||||
_engagedAt = DateTime.UtcNow;
|
||||
_longPressFired = false;
|
||||
Show();
|
||||
break;
|
||||
case TriggerMode.LongPress:
|
||||
_engaged = true;
|
||||
_engagedAt = DateTime.UtcNow;
|
||||
_longPressFired = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e)
|
||||
{
|
||||
var cfg = _config();
|
||||
if (_engaged)
|
||||
{
|
||||
if (!HotkeyService.IsComboDown(cfg.General.Hotkey))
|
||||
OnHotkeyReleased();
|
||||
else if (!_shown && !_longPressFired && cfg.General.TriggerMode == TriggerMode.LongPress &&
|
||||
(DateTime.UtcNow - _engagedAt).TotalMilliseconds >= cfg.General.LongPressMs)
|
||||
{
|
||||
_longPressFired = true;
|
||||
Show();
|
||||
}
|
||||
}
|
||||
if (_shown && Win32Interop.IsKeyDown(Win32Interop.VK_ESCAPE))
|
||||
Hide();
|
||||
if (!_engaged && !_shown)
|
||||
_timer.Stop();
|
||||
}
|
||||
|
||||
private void OnHotkeyReleased()
|
||||
{
|
||||
_engaged = false;
|
||||
var cfg = _config();
|
||||
if (!_shown) return; // 长按未到阈值,忽略本次按压
|
||||
|
||||
if (cfg.General.TriggerMode == TriggerMode.Hold)
|
||||
{
|
||||
if (cfg.General.SelectionMode == SelectionMode.Swipe)
|
||||
{
|
||||
var index = _window.HitTestScreenDip(DpiUtil.GetCursorPosDip());
|
||||
if (index is int i && i >= 0 && i < cfg.Items.Count)
|
||||
{
|
||||
ExecuteAndHide(cfg.Items[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Hide(); // 按住显示:松开即隐藏
|
||||
}
|
||||
// 长按显示:松开不影响已显示的轮盘,等待选择 / Esc / 点击外部
|
||||
}
|
||||
|
||||
public void Show()
|
||||
{
|
||||
if (_shown) return;
|
||||
var cfg = _config();
|
||||
var center = cfg.General.WheelPosition == WheelPosition.Mouse
|
||||
? DpiUtil.GetCursorPosDip()
|
||||
: GetPrimaryScreenCenterDip();
|
||||
|
||||
// 滑动选择仅在"按住显示"下有效,其余情况回退为单击
|
||||
var selectionMode = cfg.General.SelectionMode;
|
||||
if (selectionMode == SelectionMode.Swipe && cfg.General.TriggerMode != TriggerMode.Hold)
|
||||
selectionMode = SelectionMode.SingleClick;
|
||||
|
||||
_window.SelectionMode = selectionMode;
|
||||
_window.ShowWheel(cfg.Items, HubHint(cfg), center);
|
||||
ClampToVirtualScreen();
|
||||
_window.Show();
|
||||
_shown = true;
|
||||
_mouseHook.Start();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
if (!_shown) return;
|
||||
_shown = false;
|
||||
_window.Hide();
|
||||
_mouseHook.Stop();
|
||||
if (!_engaged) _timer.Stop();
|
||||
}
|
||||
|
||||
private void OnSectorPicked(int index)
|
||||
{
|
||||
var items = _config().Items;
|
||||
if (index < 0 || index >= items.Count) return;
|
||||
ExecuteAndHide(items[index]);
|
||||
}
|
||||
|
||||
private async void ExecuteAndHide(WheelItem item)
|
||||
{
|
||||
Hide();
|
||||
var result = await _runner.ExecuteAsync(item);
|
||||
if (!result.Success)
|
||||
ToastService.Show(result.Message);
|
||||
}
|
||||
|
||||
private void OnGlobalLeftButtonDown(Point screenPx)
|
||||
{
|
||||
if (!_shown) return;
|
||||
var dip = DpiUtil.PxToDip(screenPx);
|
||||
if (dip.X < _window.Left || dip.X > _window.Left + _window.Width ||
|
||||
dip.Y < _window.Top || dip.Y > _window.Top + _window.Height)
|
||||
Hide();
|
||||
}
|
||||
|
||||
private static Point GetPrimaryScreenCenterDip()
|
||||
{
|
||||
var screen = WinForms.Screen.PrimaryScreen;
|
||||
if (screen == null)
|
||||
{
|
||||
var wa = SystemParameters.WorkArea;
|
||||
return new Point(wa.Left + wa.Width / 2.0, wa.Top + wa.Height / 2.0);
|
||||
}
|
||||
var bounds = screen.Bounds;
|
||||
var px = new Point(bounds.X + bounds.Width / 2.0, bounds.Y + bounds.Height / 2.0);
|
||||
return DpiUtil.PxToDip(px);
|
||||
}
|
||||
|
||||
private void ClampToVirtualScreen()
|
||||
{
|
||||
var left = SystemParameters.VirtualScreenLeft;
|
||||
var top = SystemParameters.VirtualScreenTop;
|
||||
var width = SystemParameters.VirtualScreenWidth;
|
||||
var height = SystemParameters.VirtualScreenHeight;
|
||||
_window.Left = Math.Clamp(_window.Left, left + 4, left + width - _window.Width - 4);
|
||||
_window.Top = Math.Clamp(_window.Top, top + 4, top + height - _window.Height - 4);
|
||||
}
|
||||
|
||||
private static string HubHint(AppConfig cfg)
|
||||
{
|
||||
var (trigger, selection) = (cfg.General.TriggerMode, cfg.General.SelectionMode);
|
||||
if (selection == SelectionMode.Swipe) return "松开执行";
|
||||
var select = selection == SelectionMode.DoubleClick ? "双击选择" : "单击选择";
|
||||
// 保持单行短文本,确保中心提示行高度恒定、标题始终居中
|
||||
return trigger == TriggerMode.Hold ? "按住 · " + select : select + " · Esc";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_mouseHook.Dispose();
|
||||
_window.Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 黑夜主题 ===== -->
|
||||
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#171A21"/>
|
||||
<SolidColorBrush x:Key="PanelBackgroundBrush" Color="#1E222B"/>
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#232834"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#F2F4F8"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#9BA3B0"/>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#5B7CFF"/>
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#7490FF"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#343A46"/>
|
||||
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#262C38"/>
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#F2555A"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#43B472"/>
|
||||
<SolidColorBrush x:Key="NavSelectedBackgroundBrush" Color="#2C3550"/>
|
||||
<SolidColorBrush x:Key="NavHoverBackgroundBrush" Color="#282E3A"/>
|
||||
|
||||
<!-- 轮盘(亚克力风格:高透明度叠层) -->
|
||||
<SolidColorBrush x:Key="HubBackgroundBrush" Color="#F01E222B"/>
|
||||
<SolidColorBrush x:Key="HubStrokeBrush" Color="#40606978"/>
|
||||
<SolidColorBrush x:Key="HubTextBrush" Color="#F2F4F8"/>
|
||||
<SolidColorBrush x:Key="HubHintBrush" Color="#9BA3B0"/>
|
||||
<SolidColorBrush x:Key="SectorBorderBrush" Color="#66FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorHoverStrokeBrush" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorTextBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorGlyphBrush" Color="#FFFFFF"/>
|
||||
|
||||
<SolidColorBrush x:Key="SectorBrush0" Color="#CC3E66D6"/>
|
||||
<SolidColorBrush x:Key="SectorBrush1" Color="#CC5544D6"/>
|
||||
<SolidColorBrush x:Key="SectorBrush2" Color="#CCD6456F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush3" Color="#CC238E7E"/>
|
||||
<SolidColorBrush x:Key="SectorBrush4" Color="#CCD67A1F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush5" Color="#CC3C97C7"/>
|
||||
<SolidColorBrush x:Key="SectorBrush6" Color="#CC669F2E"/>
|
||||
<SolidColorBrush x:Key="SectorBrush7" Color="#CC9943C7"/>
|
||||
<SolidColorBrush x:Key="SectorBrush8" Color="#CCC73B3B"/>
|
||||
<SolidColorBrush x:Key="SectorBrush9" Color="#CC386CC7"/>
|
||||
<SolidColorBrush x:Key="SectorBrush10" Color="#CC3FC7A5"/>
|
||||
<SolidColorBrush x:Key="SectorBrush11" Color="#CC6F5AD6"/>
|
||||
|
||||
<!-- Toast -->
|
||||
<SolidColorBrush x:Key="ToastBackgroundBrush" Color="#F0232834"/>
|
||||
<SolidColorBrush x:Key="ToastBorderBrush" Color="#3A414E"/>
|
||||
<SolidColorBrush x:Key="ToastTextBrush" Color="#F2F4F8"/>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,46 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ===== 日间主题 ===== -->
|
||||
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#F4F6FB"/>
|
||||
<SolidColorBrush x:Key="PanelBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#1F2430"/>
|
||||
<SolidColorBrush x:Key="TextSecondaryBrush" Color="#6B7280"/>
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#4F6BFF"/>
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#3D57E0"/>
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#E5E7EB"/>
|
||||
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D"/>
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#2E9E5B"/>
|
||||
<SolidColorBrush x:Key="NavSelectedBackgroundBrush" Color="#EAF0FF"/>
|
||||
<SolidColorBrush x:Key="NavHoverBackgroundBrush" Color="#F1F4F9"/>
|
||||
|
||||
<!-- 轮盘(亚克力风格:高透明度叠层) -->
|
||||
<SolidColorBrush x:Key="HubBackgroundBrush" Color="#F2FFFFFF"/>
|
||||
<SolidColorBrush x:Key="HubStrokeBrush" Color="#40A0A6B8"/>
|
||||
<SolidColorBrush x:Key="HubTextBrush" Color="#1F2430"/>
|
||||
<SolidColorBrush x:Key="HubHintBrush" Color="#6B7280"/>
|
||||
<SolidColorBrush x:Key="SectorBorderBrush" Color="#80FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorHoverStrokeBrush" Color="#FFFFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorTextBrush" Color="#FFFFFF"/>
|
||||
<SolidColorBrush x:Key="SectorGlyphBrush" Color="#FFFFFF"/>
|
||||
|
||||
<SolidColorBrush x:Key="SectorBrush0" Color="#CC5B8DEF"/>
|
||||
<SolidColorBrush x:Key="SectorBrush1" Color="#CC6A5BE8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush2" Color="#CCE85A8C"/>
|
||||
<SolidColorBrush x:Key="SectorBrush3" Color="#CC2FAE9C"/>
|
||||
<SolidColorBrush x:Key="SectorBrush4" Color="#CCE8932F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush5" Color="#CC4FB4E8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush6" Color="#CC7FBE3F"/>
|
||||
<SolidColorBrush x:Key="SectorBrush7" Color="#CCB75AE8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush8" Color="#CCE85A5A"/>
|
||||
<SolidColorBrush x:Key="SectorBrush9" Color="#CC4F8DE8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush10" Color="#CC5AE8C8"/>
|
||||
<SolidColorBrush x:Key="SectorBrush11" Color="#CC8F8BE8"/>
|
||||
|
||||
<!-- Toast -->
|
||||
<SolidColorBrush x:Key="ToastBackgroundBrush" Color="#F2FFFFFF"/>
|
||||
<SolidColorBrush x:Key="ToastBorderBrush" Color="#E5E7EB"/>
|
||||
<SolidColorBrush x:Key="ToastTextBrush" Color="#1F2430"/>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,213 @@
|
||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<Style TargetType="{x:Type Control}">
|
||||
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
<Style TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="FontFamily" Value="Microsoft YaHei UI"/>
|
||||
<Setter Property="FontSize" Value="13"/>
|
||||
</Style>
|
||||
|
||||
<!-- 卡片 -->
|
||||
<Style x:Key="Card" TargetType="{x:Type Border}">
|
||||
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="CornerRadius" Value="12"/>
|
||||
<Setter Property="Padding" Value="18"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SectionTitle" TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="FontSize" Value="16"/>
|
||||
<Setter Property="FontWeight" Value="SemiBold"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Margin" Value="0,0,0,8"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="BodyText" TargetType="{x:Type TextBlock}">
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
</Style>
|
||||
|
||||
<!-- 侧边导航 -->
|
||||
<Style x:Key="NavButton" TargetType="{x:Type ListBoxItem}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ListBoxItem}">
|
||||
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="14,11" Margin="2">
|
||||
<ContentPresenter Content="{TemplateBinding Content}"
|
||||
TextElement.Foreground="{DynamicResource TextPrimaryBrush}"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource NavHoverBackgroundBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelected" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource NavSelectedBackgroundBrush}"/>
|
||||
<Setter Property="TextElement.FontWeight" Value="SemiBold"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 按钮 -->
|
||||
<Style x:Key="PrimaryButton" TargetType="{x:Type Button}">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter Property="Foreground" Value="White"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="18,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type Button}">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource AccentHoverBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="SecondaryButton" TargetType="{x:Type Button}">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="16,8"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type Button}">
|
||||
<Border x:Name="Bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8"
|
||||
Padding="{TemplateBinding Padding}">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextElement.Foreground="{TemplateBinding Foreground}"/>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource NavHoverBackgroundBrush}"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="Bd" Property="Opacity" Value="0.45"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="IconButton" TargetType="{x:Type Button}" BasedOn="{StaticResource SecondaryButton}">
|
||||
<Setter Property="Padding" Value="10,5"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
<Setter Property="MinWidth" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DangerButton" TargetType="{x:Type Button}" BasedOn="{StaticResource SecondaryButton}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- 复选框(现代开关样式) -->
|
||||
<Style TargetType="{x:Type CheckBox}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type CheckBox}">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Border x:Name="Box" Width="18" Height="18" CornerRadius="4"
|
||||
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1.5"
|
||||
Background="{DynamicResource InputBackgroundBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Path x:Name="Check" Data="M 3.5 9 L 7.5 13 L 14.5 4.5"
|
||||
Stroke="White" StrokeThickness="2"
|
||||
StrokeStartLineCap="Round" StrokeEndLineCap="Round"
|
||||
Visibility="Collapsed"/>
|
||||
</Border>
|
||||
<ContentPresenter VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Box" Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter TargetName="Check" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 单选框 -->
|
||||
<Style TargetType="{x:Type RadioButton}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="Cursor" Value="Hand"/>
|
||||
<Setter Property="Margin" Value="0,0,22,0"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type RadioButton}">
|
||||
<StackPanel Orientation="Horizontal" Background="Transparent">
|
||||
<Grid Width="18" Height="18" VerticalAlignment="Center" Margin="0,0,8,0">
|
||||
<Ellipse x:Name="Outer" Stroke="{DynamicResource BorderBrush}" StrokeThickness="1.5"
|
||||
Fill="{DynamicResource InputBackgroundBrush}"/>
|
||||
<Ellipse x:Name="Dot" Width="8" Height="8" Fill="{DynamicResource AccentBrush}"
|
||||
Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
<ContentPresenter VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsChecked" Value="True">
|
||||
<Setter TargetName="Outer" Property="Stroke" Value="{DynamicResource AccentBrush}"/>
|
||||
<Setter TargetName="Dot" Property="Visibility" Value="Visible"/>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.5"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- 输入控件 -->
|
||||
<Style TargetType="{x:Type TextBox}">
|
||||
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="BorderThickness" Value="1"/>
|
||||
<Setter Property="Padding" Value="8,6"/>
|
||||
<Setter Property="VerticalContentAlignment" Value="Center"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type ComboBox}">
|
||||
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
|
||||
<Setter Property="Padding" Value="6,5"/>
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type Slider}">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OneClickRun.Models;
|
||||
|
||||
namespace OneClickRun.ViewModels;
|
||||
|
||||
/// <summary>主设置窗口视图模型:包装 AppConfig,改动经 Changed 事件通知主窗口保存与应用</summary>
|
||||
public class MainViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private readonly AppConfig _config;
|
||||
private string _hotkeyStatus = string.Empty;
|
||||
private string _saveStatus = string.Empty;
|
||||
|
||||
public GeneralSettings General => _config.General;
|
||||
public ObservableCollection<WheelItem> Items { get; }
|
||||
|
||||
public MainViewModel(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
Items = new ObservableCollection<WheelItem>(config.Items);
|
||||
General.PropertyChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(General));
|
||||
Changed?.Invoke();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>快捷键注册状态提示</summary>
|
||||
public string HotkeyStatus
|
||||
{
|
||||
get => _hotkeyStatus;
|
||||
set
|
||||
{
|
||||
if (_hotkeyStatus == value) return;
|
||||
_hotkeyStatus = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>自动保存状态提示</summary>
|
||||
public string SaveStatus
|
||||
{
|
||||
get => _saveStatus;
|
||||
set
|
||||
{
|
||||
if (_saveStatus == value) return;
|
||||
_saveStatus = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>任何设置/指令变化(用于防抖保存)</summary>
|
||||
public event Action? Changed;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void AddItem(WheelItem item)
|
||||
{
|
||||
_config.Items.Add(item);
|
||||
Items.Add(item);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void ReplaceItem(WheelItem oldItem, WheelItem newItem)
|
||||
{
|
||||
var index = _config.Items.IndexOf(oldItem);
|
||||
if (index < 0) return;
|
||||
_config.Items[index] = newItem;
|
||||
Items[index] = newItem;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void RemoveItem(WheelItem item)
|
||||
{
|
||||
if (_config.Items.Remove(item))
|
||||
{
|
||||
Items.Remove(item);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
public void MoveItem(WheelItem item, int delta)
|
||||
{
|
||||
var index = _config.Items.IndexOf(item);
|
||||
var target = index + delta;
|
||||
if (index < 0 || target < 0 || target >= _config.Items.Count) return;
|
||||
(_config.Items[index], _config.Items[target]) = (_config.Items[target], _config.Items[index]);
|
||||
Items.Move(index, target);
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<UserControl x:Class="OneClickRun.Views.AboutView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="关于" Style="{StaticResource SectionTitle}" Margin="0,0,0,14"/>
|
||||
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="一键运行" FontSize="22" FontWeight="Bold"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="VersionText" Style="{StaticResource BodyText}" Margin="0,6,0,0"/>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="以游戏式快捷轮盘快速启动软件、打开文件夹、访问网址与运行 PowerShell 脚本。默认隐藏于托盘,通过全局快捷键呼出。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="运行环境" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="配置文件目录" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="ConfigPathText" Style="{StaticResource BodyText}" Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="打开" Style="{StaticResource SecondaryButton}"
|
||||
Margin="12,0,0,0" Click="OpenConfig_Click"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel>
|
||||
<TextBlock Text="日志目录" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="LogPathText" Style="{StaticResource BodyText}" Margin="0,2,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="打开" Style="{StaticResource SecondaryButton}"
|
||||
Margin="12,0,0,0" Click="OpenLog_Click"/>
|
||||
</Grid>
|
||||
<StackPanel>
|
||||
<TextBlock Text="PowerShell 状态" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<TextBlock x:Name="PwshStatusText" Style="{StaticResource BodyText}" Margin="0,2,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="使用提示" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}">
|
||||
<StackPanel>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,0,0,6"
|
||||
Text="· 按全局快捷键(默认 Ctrl+Alt+Space)呼出轮盘,选中指令后自动隐藏;Esc 或点击轮盘外区域也可隐藏。"/>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,0,0,6"
|
||||
Text="· 关闭本窗口后应用仍在托盘运行;托盘图标双击可重新打开设置,右键可显示轮盘或退出。"/>
|
||||
<TextBlock Style="{StaticResource BodyText}"
|
||||
Text="· 脚本指令使用 PowerShell 7(pwsh.exe,建议 7.6.5)执行;未安装时脚本指令会给出安装提示。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using OneClickRun.Services;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class AboutView : UserControl
|
||||
{
|
||||
public AboutView()
|
||||
{
|
||||
InitializeComponent();
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
VersionText.Text = $"版本 {version?.ToString(3) ?? "1.0.0"} · Windows 10 / 11 · WPF (.NET 8)";
|
||||
|
||||
var app = (App)Application.Current;
|
||||
ConfigPathText.Text = app.Config.ConfigPath;
|
||||
LogPathText.Text = Logger.LogDirectory;
|
||||
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
var path = await System.Threading.Tasks.Task.Run(PowerShellLocator.Find);
|
||||
var versionText = await System.Threading.Tasks.Task.Run(() => PowerShellLocator.GetVersion(path));
|
||||
PwshStatusText.Text = path == null
|
||||
? "未找到 PowerShell 7(pwsh.exe),脚本指令将无法执行。请安装 PowerShell 7.6.5。"
|
||||
: $"已找到:{path}" + (versionText != null ? $"(版本 {versionText})" : string.Empty);
|
||||
};
|
||||
}
|
||||
|
||||
private void OpenConfig_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
OpenFolder(((App)Application.Current).Config.ConfigDirectory);
|
||||
}
|
||||
|
||||
private void OpenLog_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
OpenFolder(Logger.LogDirectory);
|
||||
}
|
||||
|
||||
private static void OpenFolder(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
Process.Start(new ProcessStartInfo("explorer.exe", $"\"{path}\"") { UseShellExecute = false });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show("打开目录失败:" + ex.Message, "一键运行", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 不可见的宿主窗口:为全局热键(RegisterHotKey)与单实例广播消息提供稳定 HWND。
|
||||
/// 主设置窗口隐藏到托盘后句柄依然有效。
|
||||
/// </summary>
|
||||
internal sealed class HotkeyHostWindow : Window
|
||||
{
|
||||
public HotkeyHostWindow()
|
||||
{
|
||||
WindowStyle = WindowStyle.None;
|
||||
ResizeMode = ResizeMode.NoResize;
|
||||
ShowInTaskbar = false;
|
||||
ShowActivated = false;
|
||||
AllowsTransparency = true;
|
||||
Background = Brushes.Transparent;
|
||||
Opacity = 0;
|
||||
Width = 1;
|
||||
Height = 1;
|
||||
WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
Left = -100;
|
||||
Top = -100;
|
||||
Focusable = false;
|
||||
IsHitTestVisible = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<Window x:Class="OneClickRun.Views.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="clr-namespace:OneClickRun.Views"
|
||||
Title="一键运行"
|
||||
Width="960" Height="660" MinWidth="860" MinHeight="580"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource WindowBackgroundBrush}">
|
||||
<Grid Margin="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Border Grid.Column="0" Background="{DynamicResource PanelBackgroundBrush}" CornerRadius="12"
|
||||
Margin="0,0,14,0" Padding="6">
|
||||
<DockPanel>
|
||||
<StackPanel DockPanel.Dock="Top" Margin="4,10,4,18">
|
||||
<TextBlock Text="一键运行" FontSize="20" FontWeight="Bold"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" HorizontalAlignment="Center"/>
|
||||
<TextBlock Text="快捷轮盘启动器" FontSize="12"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"
|
||||
Margin="0,4,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel DockPanel.Dock="Bottom" Margin="4,0,4,8">
|
||||
<Button x:Name="ShowWheelButton" Content="预览轮盘" Style="{StaticResource SecondaryButton}"
|
||||
Margin="0,0,0,8" Click="ShowWheelButton_Click"/>
|
||||
<TextBlock Text="{Binding SaveStatus}" FontSize="11"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" HorizontalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<ListBox x:Name="NavList" BorderThickness="0" Background="Transparent"
|
||||
SelectionChanged="NavList_SelectionChanged"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||
<ListBoxItem Style="{StaticResource NavButton}" Content="轮盘指令" Tag="items" IsSelected="True"/>
|
||||
<ListBoxItem Style="{StaticResource NavButton}" Content="系统设置" Tag="general"/>
|
||||
<ListBoxItem Style="{StaticResource NavButton}" Content="关于" Tag="about"/>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<views:WheelItemsView x:Name="ItemsView"/>
|
||||
<views:SettingsGeneralView x:Name="GeneralView" Visibility="Collapsed"/>
|
||||
<views:AboutView x:Name="AboutView" Visibility="Collapsed"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Threading;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Services;
|
||||
using OneClickRun.ViewModels;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly MainViewModel _vm;
|
||||
private readonly DispatcherTimer _saveTimer;
|
||||
private bool _ready;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
var app = (App)Application.Current;
|
||||
_vm = new MainViewModel(app.Config.Current);
|
||||
DataContext = _vm;
|
||||
ItemsView.DataContext = _vm;
|
||||
GeneralView.DataContext = _vm;
|
||||
AboutView.DataContext = _vm;
|
||||
Icon = AppIcon.GetWindowIcon();
|
||||
|
||||
_vm.Changed += OnConfigChanged;
|
||||
_saveTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_saveTimer.Tick += (_, _) =>
|
||||
{
|
||||
_saveTimer.Stop();
|
||||
SaveAndApply();
|
||||
};
|
||||
NotifyHotkeyStatus();
|
||||
_ready = true;
|
||||
}
|
||||
|
||||
private void OnConfigChanged()
|
||||
{
|
||||
if (!_ready) return;
|
||||
_saveTimer.Stop();
|
||||
_saveTimer.Start();
|
||||
}
|
||||
|
||||
private void SaveAndApply()
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
app.Config.Save();
|
||||
app.ApplyAll();
|
||||
_vm.SaveStatus = $"已保存 {DateTime.Now:HH:mm:ss}";
|
||||
NotifyHotkeyStatus();
|
||||
}
|
||||
|
||||
public void NotifyHotkeyStatus()
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
if (!app.Hotkeys.IsRegistered)
|
||||
{
|
||||
var binding = app.Config.Current.General.Hotkey;
|
||||
_vm.HotkeyStatus = binding.IsValid
|
||||
? "快捷键注册失败,可能与其他程序冲突"
|
||||
: "未设置快捷键(可在左侧系统设置中录制)";
|
||||
}
|
||||
else
|
||||
{
|
||||
_vm.HotkeyStatus = "快捷键已注册";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>挂接单实例广播消息:收到后唤起本窗口</summary>
|
||||
public void HookShowSettingsMessage(uint message)
|
||||
{
|
||||
var helper = new WindowInteropHelper(this);
|
||||
helper.EnsureHandle(); // 窗口尚未显示时强制创建 HWND
|
||||
var hwnd = helper.Handle;
|
||||
HwndSource.FromHwnd(hwnd)?.AddHook((IntPtr h, int msg, IntPtr w, IntPtr l, ref bool handled) =>
|
||||
{
|
||||
if (msg == (int)message)
|
||||
{
|
||||
Dispatcher.BeginInvoke(ShowSettingsAndActivate);
|
||||
handled = true;
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
});
|
||||
}
|
||||
|
||||
public void ShowSettingsAndActivate()
|
||||
{
|
||||
Show();
|
||||
WindowState = WindowState.Normal;
|
||||
Activate();
|
||||
}
|
||||
|
||||
private void ShowWheelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
((App)Application.Current).Wheel?.Show();
|
||||
}
|
||||
|
||||
private void NavList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
// XAML 中 IsSelected="True" 会在 InitializeComponent 阶段触发本事件,
|
||||
// 此时子视图字段尚未赋值,需先判空。
|
||||
if (ItemsView == null || GeneralView == null || AboutView == null) return;
|
||||
var tag = (NavList.SelectedItem as ListBoxItem)?.Tag as string;
|
||||
ItemsView.Visibility = tag == "items" ? Visibility.Visible : Visibility.Collapsed;
|
||||
GeneralView.Visibility = tag == "general" ? Visibility.Visible : Visibility.Collapsed;
|
||||
AboutView.Visibility = tag == "about" ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
protected override void OnClosing(CancelEventArgs e)
|
||||
{
|
||||
var app = (App)Application.Current;
|
||||
if (!app.IsExitRequested)
|
||||
{
|
||||
// 关闭 = 隐藏到托盘
|
||||
e.Cancel = true;
|
||||
SaveAndApply();
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
SaveAndApply();
|
||||
base.OnClosing(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Window x:Class="OneClickRun.Views.RadialMenuWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="轮盘"
|
||||
AllowsTransparency="True" Background="Transparent"
|
||||
WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False"
|
||||
Topmost="True" ShowActivated="False" Focusable="False"
|
||||
WindowStartupLocation="Manual"
|
||||
Width="536" Height="536">
|
||||
<Canvas x:Name="Root"/>
|
||||
</Window>
|
||||
@@ -0,0 +1,256 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Shapes;
|
||||
using Path = System.Windows.Shapes.Path;
|
||||
using OneClickRun.Helpers;
|
||||
using OneClickRun.Models;
|
||||
using SelectionMode = OneClickRun.Models.SelectionMode;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 快捷轮盘窗口:透明背景 + 亚克力风格扇形(等间隙几何)。
|
||||
/// 窗口尺寸 = 2 × (外半径 + 阴影边距),画布中心 CanvasCenter 始终位于窗口中心。
|
||||
/// </summary>
|
||||
public partial class RadialMenuWindow : Window
|
||||
{
|
||||
public const double InnerRadius = 70;
|
||||
public const double OuterRadius = 220;
|
||||
public const double GapWidth = 10;
|
||||
public const double FramePadding = 48;
|
||||
public static readonly Point CanvasCenter = new(OuterRadius + FramePadding, OuterRadius + FramePadding);
|
||||
|
||||
private IReadOnlyList<WheelItem> _items = Array.Empty<WheelItem>();
|
||||
private readonly List<Path> _sectors = new();
|
||||
private SelectionMode _selectionMode = SelectionMode.SingleClick;
|
||||
private int _hovered = -1;
|
||||
private TextBlock? _hubHint;
|
||||
private string _hubDefaultHint = string.Empty;
|
||||
|
||||
public RadialMenuWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
/// <summary>单击/双击选择时触发(滑动选择由 WheelWindowManager 在松开时命中测试)</summary>
|
||||
public event Action<int>? SectorPicked;
|
||||
|
||||
public SelectionMode SelectionMode
|
||||
{
|
||||
get => _selectionMode;
|
||||
set => _selectionMode = value;
|
||||
}
|
||||
|
||||
public void ShowWheel(IReadOnlyList<WheelItem> items, string hubHint, Point screenCenterDip)
|
||||
{
|
||||
_items = items;
|
||||
_hovered = -1;
|
||||
Root.Children.Clear();
|
||||
_sectors.Clear();
|
||||
|
||||
var count = Math.Max(1, items.Count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var sector = new Path
|
||||
{
|
||||
Data = WheelGeometry.CreateSectorGeometry(CanvasCenter, InnerRadius, OuterRadius, GapWidth, i, count),
|
||||
Fill = GetSectorBrush(i),
|
||||
Stroke = (Brush)TryFindResource("SectorBorderBrush") ?? Brushes.Transparent,
|
||||
StrokeThickness = 1,
|
||||
Tag = i,
|
||||
Cursor = Cursors.Hand,
|
||||
};
|
||||
sector.MouseEnter += Sector_MouseEnter;
|
||||
sector.MouseLeave += Sector_MouseLeave;
|
||||
sector.MouseLeftButtonDown += Sector_MouseLeftButtonDown;
|
||||
sector.MouseLeftButtonUp += Sector_MouseLeftButtonUp;
|
||||
Root.Children.Add(sector);
|
||||
_sectors.Add(sector);
|
||||
AddLabel(items[i], i, count);
|
||||
}
|
||||
AddHub(hubHint);
|
||||
|
||||
Left = screenCenterDip.X - (OuterRadius + FramePadding);
|
||||
Top = screenCenterDip.Y - (OuterRadius + FramePadding);
|
||||
}
|
||||
|
||||
/// <summary>内部调试:模拟悬停指定扇形(用于快照验证中心文本居中)</summary>
|
||||
internal void SimulateHover(int index) => Highlight(index);
|
||||
|
||||
/// <summary>滑动选择命中测试:屏幕坐标(DIP) → 扇形索引,间隙/中心/圆环外返回 null</summary>
|
||||
public int? HitTestScreenDip(Point screenDip)
|
||||
{
|
||||
var client = PointFromScreen(screenDip);
|
||||
return WheelGeometry.HitTest(client, CanvasCenter, InnerRadius, OuterRadius, GapWidth, Math.Max(1, _items.Count));
|
||||
}
|
||||
|
||||
private static Brush GetSectorBrush(int index) =>
|
||||
Application.Current?.TryFindResource($"SectorBrush{index % 12}") as Brush ?? Brushes.CornflowerBlue;
|
||||
|
||||
private static string GlyphFor(WheelItemType type) => type switch
|
||||
{
|
||||
WheelItemType.App => "\uE768",
|
||||
WheelItemType.Folder => "\uE8B7",
|
||||
WheelItemType.Url => "\uE774",
|
||||
WheelItemType.Script => "\uE756",
|
||||
_ => "\uE768",
|
||||
};
|
||||
|
||||
private void AddLabel(WheelItem item, int index, int count)
|
||||
{
|
||||
var mid = WheelGeometry.SectorMidPoint(CanvasCenter, InnerRadius, OuterRadius, index, count);
|
||||
var panel = new StackPanel { IsHitTestVisible = false, HorizontalAlignment = HorizontalAlignment.Center };
|
||||
|
||||
var glyph = new TextBlock
|
||||
{
|
||||
Text = GlyphFor(item.Type),
|
||||
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
||||
FontSize = 20,
|
||||
Foreground = (Brush)TryFindResource("SectorGlyphBrush") ?? Brushes.White,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 0, 2),
|
||||
};
|
||||
var name = new TextBlock
|
||||
{
|
||||
Text = TrimName(item.Name),
|
||||
FontSize = 13,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = (Brush)TryFindResource("SectorTextBrush") ?? Brushes.White,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxWidth = LabelMaxWidth(count),
|
||||
};
|
||||
panel.Children.Add(glyph);
|
||||
panel.Children.Add(name);
|
||||
panel.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
|
||||
var w = panel.DesiredSize.Width;
|
||||
var h = panel.DesiredSize.Height;
|
||||
|
||||
// 按屏幕方向(水平正向)显示:图标在上、名称在下,不做旋转,便于阅读
|
||||
Canvas.SetLeft(panel, mid.X - w / 2);
|
||||
Canvas.SetTop(panel, mid.Y - h / 2);
|
||||
Root.Children.Add(panel);
|
||||
}
|
||||
|
||||
/// <summary>标签最大宽度:扇形较多时收窄,避免相邻扇形文字重叠</summary>
|
||||
private static double LabelMaxWidth(int count)
|
||||
{
|
||||
var midRadius = (InnerRadius + OuterRadius) / 2;
|
||||
var chord = 2 * midRadius * Math.Sin(Math.PI / count); // 相邻扇形中点间距
|
||||
return Math.Max(48, Math.Min(OuterRadius - InnerRadius - GapWidth - 8, chord - 8));
|
||||
}
|
||||
|
||||
private void AddHub(string hubHint)
|
||||
{
|
||||
_hubDefaultHint = hubHint;
|
||||
var hubDiameter = 2 * (InnerRadius - 16);
|
||||
var hub = new Ellipse
|
||||
{
|
||||
Width = hubDiameter,
|
||||
Height = hubDiameter,
|
||||
Fill = (Brush)TryFindResource("HubBackgroundBrush") ?? Brushes.White,
|
||||
Stroke = (Brush)TryFindResource("HubStrokeBrush") ?? Brushes.Gray,
|
||||
StrokeThickness = 1,
|
||||
};
|
||||
Canvas.SetLeft(hub, CanvasCenter.X - hubDiameter / 2);
|
||||
Canvas.SetTop(hub, CanvasCenter.Y - hubDiameter / 2);
|
||||
Root.Children.Add(hub);
|
||||
|
||||
// 固定尺寸的居中容器:悬停时提示文本长度变化,容器位置不变,内容始终整体居中
|
||||
var container = new Grid
|
||||
{
|
||||
Width = hubDiameter,
|
||||
Height = hubDiameter,
|
||||
IsHitTestVisible = false,
|
||||
};
|
||||
var panel = new StackPanel
|
||||
{
|
||||
IsHitTestVisible = false,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
MaxWidth = hubDiameter - 20,
|
||||
};
|
||||
panel.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "一键运行",
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = (Brush)TryFindResource("HubTextBrush") ?? Brushes.Black,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
});
|
||||
_hubHint = new TextBlock
|
||||
{
|
||||
Text = hubHint,
|
||||
FontSize = 11,
|
||||
Foreground = (Brush)TryFindResource("HubHintBrush") ?? Brushes.Gray,
|
||||
TextAlignment = TextAlignment.Center,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
Margin = new Thickness(0, 2, 0, 0),
|
||||
};
|
||||
panel.Children.Add(_hubHint);
|
||||
container.Children.Add(panel);
|
||||
Canvas.SetLeft(container, CanvasCenter.X - hubDiameter / 2);
|
||||
Canvas.SetTop(container, CanvasCenter.Y - hubDiameter / 2);
|
||||
Root.Children.Add(container);
|
||||
}
|
||||
|
||||
private void Sector_MouseEnter(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (sender is Path path && path.Tag is int index)
|
||||
Highlight(index);
|
||||
}
|
||||
|
||||
private void Sector_MouseLeave(object sender, MouseEventArgs e) => Unhighlight();
|
||||
|
||||
private void Sector_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not Path path || path.Tag is not int index) return;
|
||||
Highlight(index);
|
||||
if (_selectionMode == SelectionMode.DoubleClick && e.ClickCount == 2)
|
||||
Pick(index);
|
||||
}
|
||||
|
||||
private void Sector_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not Path path || path.Tag is not int index) return;
|
||||
if (_selectionMode == SelectionMode.SingleClick)
|
||||
Pick(index);
|
||||
}
|
||||
|
||||
private void Pick(int index) => SectorPicked?.Invoke(index);
|
||||
|
||||
private void Highlight(int index)
|
||||
{
|
||||
if (_hovered >= 0 && _hovered < _sectors.Count)
|
||||
{
|
||||
_sectors[_hovered].Stroke = (Brush)TryFindResource("SectorBorderBrush") ?? Brushes.Transparent;
|
||||
_sectors[_hovered].StrokeThickness = 1;
|
||||
}
|
||||
_hovered = index;
|
||||
if (index < 0 || index >= _sectors.Count) return;
|
||||
_sectors[index].Stroke = (Brush)TryFindResource("SectorHoverStrokeBrush") ?? Brushes.White;
|
||||
_sectors[index].StrokeThickness = 2.5;
|
||||
if (_hubHint != null && index < _items.Count)
|
||||
_hubHint.Text = TrimName(_items[index].Name);
|
||||
}
|
||||
|
||||
private void Unhighlight()
|
||||
{
|
||||
if (_hovered >= 0 && _hovered < _sectors.Count)
|
||||
{
|
||||
_sectors[_hovered].Stroke = (Brush)TryFindResource("SectorBorderBrush") ?? Brushes.Transparent;
|
||||
_sectors[_hovered].StrokeThickness = 1;
|
||||
}
|
||||
_hovered = -1;
|
||||
if (_hubHint != null)
|
||||
_hubHint.Text = _hubDefaultHint;
|
||||
}
|
||||
|
||||
private static string TrimName(string name) =>
|
||||
string.IsNullOrWhiteSpace(name) ? "未命名" : name.Length <= 10 ? name : name[..10] + "…";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<UserControl x:Class="OneClickRun.Views.SettingsGeneralView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:OneClickRun.Controls">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="0,0,8,0">
|
||||
<StackPanel>
|
||||
<TextBlock Text="系统设置" Style="{StaticResource SectionTitle}" Margin="0,0,0,2"/>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,0,0,14"
|
||||
Text="所有修改即时生效并自动保存。"/>
|
||||
|
||||
<TextBlock Text="常规" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<CheckBox Content="开机启动(登录 Windows 后自动在后台运行)"
|
||||
IsChecked="{Binding General.AutoStart}" Margin="0,0,0,10"/>
|
||||
<CheckBox Content="启用轮盘(关闭后全局快捷键不再呼出轮盘)"
|
||||
IsChecked="{Binding General.WheelEnabled}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="轮盘显示位置" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="position" Content="屏幕中心"
|
||||
IsChecked="{Binding General.WheelPosition, Converter={StaticResource EnumEquals}, ConverterParameter=ScreenCenter}"/>
|
||||
<RadioButton GroupName="position" Content="鼠标位置"
|
||||
IsChecked="{Binding General.WheelPosition, Converter={StaticResource EnumEquals}, ConverterParameter=Mouse}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="全局快捷键" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<controls:HotkeyRecorder Width="200"
|
||||
HotkeyText="{Binding General.HotkeyText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
|
||||
<Button Grid.Column="1" Content="清空" Style="{StaticResource SecondaryButton}"
|
||||
Margin="10,0,0,0" Click="ClearHotkey_Click"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding HotkeyStatus}" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" Margin="14,0,0,0"
|
||||
TextWrapping="Wrap"/>
|
||||
</Grid>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="点击输入框后按下组合键即可录制,例如 Ctrl+Alt+Space;清空表示不注册快捷键。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="呼出轮盘方式" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="trigger" Content="点击显示"
|
||||
IsChecked="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=Click}"/>
|
||||
<RadioButton GroupName="trigger" Content="长按显示"
|
||||
IsChecked="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=LongPress}"/>
|
||||
<RadioButton GroupName="trigger" Content="按住显示"
|
||||
IsChecked="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=Hold}"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
|
||||
<TextBlock Text="长按阈值" VerticalAlignment="Center" Foreground="{DynamicResource TextPrimaryBrush}"/>
|
||||
<Slider Minimum="300" Maximum="1500" Width="220" Margin="12,0,10,0"
|
||||
Value="{Binding General.LongPressMs}"
|
||||
IsEnabled="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=LongPress}"/>
|
||||
<TextBlock Text="{Binding General.LongPressMs, StringFormat={}{0} 毫秒}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="点击显示:按一次快捷键显示,再按一次隐藏;长按显示:按住超过阈值后显示;按住显示:按住时显示、松开即隐藏(可与滑动选择配合)。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="指令选择方式" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="selection" Content="单击选择"
|
||||
IsChecked="{Binding General.SelectionMode, Converter={StaticResource EnumEquals}, ConverterParameter=SingleClick}"/>
|
||||
<RadioButton GroupName="selection" Content="双击选择"
|
||||
IsChecked="{Binding General.SelectionMode, Converter={StaticResource EnumEquals}, ConverterParameter=DoubleClick}"/>
|
||||
<RadioButton GroupName="selection" Content="滑动选择"
|
||||
IsChecked="{Binding General.SelectionMode, Converter={StaticResource EnumEquals}, ConverterParameter=Swipe}"
|
||||
IsEnabled="{Binding General.TriggerMode, Converter={StaticResource EnumEquals}, ConverterParameter=Hold}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource BodyText}" Margin="0,10,0,0"
|
||||
Text="滑动选择仅在“按住显示”时可用:按住快捷键移动鼠标,松开时执行鼠标所在方位的指令。"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock Text="外观主题" Style="{StaticResource SectionTitle}"/>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,0,14">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<RadioButton GroupName="theme" Content="日间模式"
|
||||
IsChecked="{Binding General.Theme, Converter={StaticResource EnumEquals}, ConverterParameter=Light}"/>
|
||||
<RadioButton GroupName="theme" Content="黑夜模式"
|
||||
IsChecked="{Binding General.Theme, Converter={StaticResource EnumEquals}, ConverterParameter=Dark}"/>
|
||||
<RadioButton GroupName="theme" Content="跟随系统"
|
||||
IsChecked="{Binding General.Theme, Converter={StaticResource EnumEquals}, ConverterParameter=System}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using OneClickRun.ViewModels;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class SettingsGeneralView : UserControl
|
||||
{
|
||||
public SettingsGeneralView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void ClearHotkey_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
((MainViewModel)DataContext).General.HotkeyText = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Window x:Class="OneClickRun.Views.ToastWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="提示"
|
||||
AllowsTransparency="True" Background="Transparent"
|
||||
WindowStyle="None" ResizeMode="NoResize" ShowInTaskbar="False"
|
||||
Topmost="True" ShowActivated="False" Focusable="False"
|
||||
WindowStartupLocation="Manual" SizeToContent="WidthAndHeight"
|
||||
IsHitTestVisible="False">
|
||||
<Border x:Name="Root" CornerRadius="10" MaxWidth="440"
|
||||
Background="{DynamicResource ToastBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource ToastBorderBrush}" BorderThickness="1"
|
||||
Padding="18,12">
|
||||
<TextBlock x:Name="MessageText" TextWrapping="Wrap" FontSize="13"
|
||||
Foreground="{DynamicResource ToastTextBrush}"/>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media.Animation;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class ToastWindow : Window
|
||||
{
|
||||
public ToastWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
Opacity = 0;
|
||||
}
|
||||
|
||||
public void ShowMessage(string message)
|
||||
{
|
||||
MessageText.Text = message;
|
||||
UpdateLayout();
|
||||
var workArea = SystemParameters.WorkArea;
|
||||
Left = workArea.Right - ActualWidth - 20;
|
||||
Top = workArea.Bottom - ActualHeight - 20;
|
||||
|
||||
BeginAnimation(OpacityProperty, null);
|
||||
Opacity = 0;
|
||||
var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(160));
|
||||
var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(420))
|
||||
{
|
||||
BeginTime = TimeSpan.FromMilliseconds(3600),
|
||||
};
|
||||
fadeOut.Completed += (_, _) => { };
|
||||
BeginAnimation(OpacityProperty, fadeIn);
|
||||
BeginAnimation(OpacityProperty, fadeOut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<UserControl x:Class="OneClickRun.Views.WheelItemsView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Grid.Row="0" Margin="0,0,0,6">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="轮盘指令" Style="{StaticResource SectionTitle}" Margin="0" VerticalAlignment="Center"/>
|
||||
<TextBlock Text="{Binding Items.Count, StringFormat=共 {0} 个}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}" VerticalAlignment="Center" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Content="+ 新建指令" Style="{StaticResource PrimaryButton}" Click="Add_Click"/>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="1" Style="{StaticResource BodyText}" Margin="0,0,0,12"
|
||||
Text="指令按列表顺序在轮盘上顺时针排布,1–12 个指令效果最佳。"/>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Style="{StaticResource Card}" Margin="0,0,2,10" Padding="16,12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0" Margin="0,0,12,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="{Binding Name}" FontSize="14" FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource TextPrimaryBrush}" VerticalAlignment="Center"/>
|
||||
<Border Background="{Binding Type, Converter={StaticResource EnumBadge}}"
|
||||
CornerRadius="10" Padding="8,2" Margin="10,0,0,0" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Type, Converter={StaticResource EnumDisplay}}"
|
||||
FontSize="11" Foreground="White"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding Path}" Style="{StaticResource BodyText}" Margin="0,6,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<TextBlock Text="{Binding Args, StringFormat=参数:{0}}" Style="{StaticResource BodyText}"
|
||||
Margin="0,3,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<Button Content="↑" Style="{StaticResource IconButton}" ToolTip="上移" Click="MoveUp_Click"/>
|
||||
<Button Content="↓" Style="{StaticResource IconButton}" ToolTip="下移" Margin="6,0,0,0" Click="MoveDown_Click"/>
|
||||
<Button Content="编辑" Style="{StaticResource SecondaryButton}" Margin="6,0,0,0" Click="Edit_Click"/>
|
||||
<Button Content="删除" Style="{StaticResource DangerButton}" Margin="6,0,0,0" Click="Delete_Click"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<TextBlock Text="暂无指令,点击右上角“新建指令”添加"
|
||||
Visibility="{Binding Items.Count, Converter={StaticResource CountToVisibility}}"
|
||||
Foreground="{DynamicResource TextSecondaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using OneClickRun.Controls;
|
||||
using OneClickRun.Models;
|
||||
using OneClickRun.ViewModels;
|
||||
|
||||
namespace OneClickRun.Views;
|
||||
|
||||
public partial class WheelItemsView : UserControl
|
||||
{
|
||||
public WheelItemsView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private MainViewModel Vm => (MainViewModel)DataContext;
|
||||
|
||||
private void Add_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var editor = new WheelItemEditor { Owner = Window.GetWindow(this) };
|
||||
if (editor.ShowDialog() == true && editor.Result != null)
|
||||
Vm.AddItem(editor.Result);
|
||||
}
|
||||
|
||||
private void Edit_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not WheelItem item) return;
|
||||
var editor = new WheelItemEditor(item.Clone()) { Owner = Window.GetWindow(this) };
|
||||
if (editor.ShowDialog() == true && editor.Result != null)
|
||||
Vm.ReplaceItem(item, editor.Result);
|
||||
}
|
||||
|
||||
private void Delete_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is not WheelItem item) return;
|
||||
var answer = MessageBox.Show(Window.GetWindow(this),
|
||||
$"确定删除指令「{item.Name}」吗?", "一键运行",
|
||||
MessageBoxButton.YesNo, MessageBoxImage.Question);
|
||||
if (answer == MessageBoxResult.Yes)
|
||||
Vm.RemoveItem(item);
|
||||
}
|
||||
|
||||
private void MoveUp_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is WheelItem item)
|
||||
Vm.MoveItem(item, -1);
|
||||
}
|
||||
|
||||
private void MoveDown_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if ((sender as FrameworkElement)?.DataContext is WheelItem item)
|
||||
Vm.MoveItem(item, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="OneClickRun.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / Windows 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -0,0 +1,11 @@
|
||||
# 一键运行示例脚本(由 PowerShell 7.6.5 执行)
|
||||
# 你可以把任意 .ps1 脚本添加为轮盘指令
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ========================================"
|
||||
Write-Host " 你好,一键运行!"
|
||||
Write-Host " 当前时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
|
||||
Write-Host " 当前目录: $PWD"
|
||||
Write-Host " ========================================"
|
||||
Write-Host ""
|
||||
Read-Host -Prompt "按回车键退出"
|
||||
Reference in New Issue
Block a user