feat: 初始提交——一键运行快捷轮盘工具(WPF + C#)

This commit is contained in:
2026-08-30 15:44:15 +08:00
commit 2081a938bf
63 changed files with 4214 additions and 0 deletions
+18
View File
@@ -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>
+213
View File
@@ -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 7pwsh.exe,建议 7.6.5)执行,可在“参数”中追加脚本参数。",
_ => string.Empty,
};
}
}
+86
View File
@@ -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;
}
+62
View File
@@ -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);
}
+36
View File
@@ -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();
}
+58
View File
@@ -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));
}
+104
View File
@@ -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;
}
}
+129
View File
@@ -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;
}
}
+9
View File
@@ -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();
}
+16
View File
@@ -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 }
+67
View File
@@ -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;
}
}
+12
View File
@@ -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 };
}
+20
View File
@@ -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,
};
}
+33
View File
@@ -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;
}
}
}
+145
View File
@@ -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 7pwsh.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;
}
}
+140
View File
@@ -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"),
},
},
};
}
}
+80
View File
@@ -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();
}
+40
View File
@@ -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 7pwsh.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()}";
}
}
+69
View File
@@ -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;
}
}
}
+28
View File
@@ -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_HOTKEYMOD_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();
}
}
+46
View File
@@ -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>
+46
View File
@@ -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>
+213
View File
@@ -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));
}
+68
View File
@@ -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 7pwsh.exe,建议 7.6.5)执行;未安装时脚本指令会给出安装提示。"/>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
+54
View File
@@ -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 7pwsh.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);
}
}
}
+29
View File
@@ -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;
}
}
+47
View File
@@ -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>
+126
View File
@@ -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;
}
}
+17
View File
@@ -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>
+33
View File
@@ -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);
}
}
+71
View File
@@ -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);
}
}
+22
View File
@@ -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>
+11
View File
@@ -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 "按回车键退出"