From 71833a32ffcc8bcf883ee9de10bc6f3f8d445746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BE=89=E9=B8=AD=E8=9B=8B?= Date: Mon, 4 May 2026 16:05:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(gear-task):=20=E4=B8=BA=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E5=99=A8=E6=B7=BB=E5=8A=A0=E5=8F=AF=E8=A7=86?= =?UTF-8?q?=E5=8C=96=20Cron=20=E8=A1=A8=E8=BE=BE=E5=BC=8F=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入新的 CronSchedulePicker 控件,提供预设(每日/每周)和手动两种输入模式,改善用户体验。用户可通过直观界面选择执行时间,无需手动编写复杂的 Cron 表达式。同时更新了默认触发时间并优化了相关验证逻辑。 --- .../View/Controls/CronSchedulePicker.xaml | 90 ++++ .../View/Controls/CronSchedulePicker.xaml.cs | 388 ++++++++++++++++++ .../Windows/GearTask/AddTriggerDialog.xaml | 25 +- .../GearTask/AddTriggerDialogViewModel.cs | 43 +- 4 files changed, 539 insertions(+), 7 deletions(-) create mode 100644 BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml create mode 100644 BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml.cs diff --git a/BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml b/BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml new file mode 100644 index 00000000..712e916d --- /dev/null +++ b/BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml.cs b/BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml.cs new file mode 100644 index 00000000..11a40af0 --- /dev/null +++ b/BetterGenshinImpact/View/Controls/CronSchedulePicker.xaml.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; + +namespace BetterGenshinImpact.View.Controls; + +public enum CronScheduleMode +{ + Daily, + Weekly +} + +public partial class CronSchedulePicker : UserControl +{ + private static readonly string[] OrderedWeekDays = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; + private const string DefaultCronExpression = "1 0 4 * * ?"; + + private bool _isInternalUpdate; + private CronScheduleMode _mode = CronScheduleMode.Daily; + + public static readonly DependencyProperty CronExpressionProperty = + DependencyProperty.Register( + nameof(CronExpression), + typeof(string), + typeof(CronSchedulePicker), + new FrameworkPropertyMetadata( + DefaultCronExpression, + FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, + OnCronExpressionChanged)); + + public string CronExpression + { + get => (string)GetValue(CronExpressionProperty); + set => SetValue(CronExpressionProperty, value); + } + + public static readonly DependencyProperty DisplayTextProperty = + DependencyProperty.Register( + nameof(DisplayText), + typeof(string), + typeof(CronSchedulePicker), + new PropertyMetadata("每天 04:00:01 执行")); + + public string DisplayText + { + get => (string)GetValue(DisplayTextProperty); + set => SetValue(DisplayTextProperty, value); + } + + public CronSchedulePicker() + { + InitializeComponent(); + Loaded += OnLoaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + Loaded -= OnLoaded; + InitTimeComboBox(HourComboBox, 24); + InitTimeComboBox(MinuteComboBox, 60); + InitTimeComboBox(SecondComboBox, 60); + ApplyCronToEditor(CronExpression); + } + + private static void OnCronExpressionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not CronSchedulePicker control || control._isInternalUpdate) + { + return; + } + + control.ApplyCronToEditor(e.NewValue?.ToString() ?? string.Empty); + } + + private static void InitTimeComboBox(ComboBox comboBox, int count) + { + comboBox.ItemsSource = Enumerable.Range(0, count).Select(i => i.ToString("00")).ToList(); + } + + private void TriggerButton_OnClick(object sender, RoutedEventArgs e) + { + PickerPopup.IsOpen = true; + } + + private void CloseButton_OnClick(object sender, RoutedEventArgs e) + { + PickerPopup.IsOpen = false; + } + + private void ModeComboBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isInternalUpdate) + { + return; + } + + _mode = GetModeFromCombo(); + WeeklyPanel.Visibility = _mode == CronScheduleMode.Weekly ? Visibility.Visible : Visibility.Collapsed; + UpdateOutput(); + } + + private void TimeComboBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isInternalUpdate) + { + return; + } + + UpdateOutput(); + } + + private void WeekdayCheckBox_OnChanged(object sender, RoutedEventArgs e) + { + if (_isInternalUpdate || _mode != CronScheduleMode.Weekly) + { + return; + } + + UpdateOutput(); + } + + private void UpdateOutput() + { + if (HourComboBox.SelectedItem == null || MinuteComboBox.SelectedItem == null || SecondComboBox.SelectedItem == null) + { + return; + } + + var hour = HourComboBox.SelectedItem.ToString() ?? "00"; + var minute = MinuteComboBox.SelectedItem.ToString() ?? "00"; + var second = SecondComboBox.SelectedItem.ToString() ?? "00"; + + string cron; + string display; + + if (_mode == CronScheduleMode.Daily) + { + cron = $"{second} {minute} {hour} * * ?"; + display = $"每天 {hour}:{minute}:{second} 执行"; + } + else + { + var selectedDays = GetSelectedWeekDays(); + if (selectedDays.Count == 0) + { + cron = string.Empty; + display = $"每周(请选择周几) {hour}:{minute}:{second} 执行"; + } + else + { + cron = $"{second} {minute} {hour} ? * {string.Join(",", selectedDays)}"; + display = $"每周{ToChineseWeekDays(selectedDays)} {hour}:{minute}:{second} 执行"; + } + } + + _isInternalUpdate = true; + SetCurrentValue(CronExpressionProperty, cron); + SetCurrentValue(DisplayTextProperty, display); + _isInternalUpdate = false; + } + + private void ApplyCronToEditor(string cron) + { + _isInternalUpdate = true; + try + { + if (string.IsNullOrWhiteSpace(cron)) + { + SetDefaultEditorState(); + return; + } + + if (TryParseDaily(cron, out var dailySecond, out var dailyMinute, out var dailyHour)) + { + SetMode(CronScheduleMode.Daily); + SetTime(dailyHour, dailyMinute, dailySecond); + ClearAllWeekdaySelection(); + UpdateOutput(); + return; + } + + if (TryParseWeekly(cron, out var weeklySecond, out var weeklyMinute, out var weeklyHour, out var weekDays)) + { + SetMode(CronScheduleMode.Weekly); + SetTime(weeklyHour, weeklyMinute, weeklySecond); + SetWeekdaySelection(weekDays); + UpdateOutput(); + return; + } + + SetCurrentValue(DisplayTextProperty, $"自定义 Cron: {cron}"); + } + finally + { + _isInternalUpdate = false; + } + } + + private void SetDefaultEditorState() + { + SetMode(CronScheduleMode.Daily); + SetTime(4, 0, 1); + ClearAllWeekdaySelection(); + UpdateOutput(); + } + + private void SetMode(CronScheduleMode mode) + { + _mode = mode; + ModeComboBox.SelectedIndex = mode == CronScheduleMode.Daily ? 0 : 1; + WeeklyPanel.Visibility = mode == CronScheduleMode.Weekly ? Visibility.Visible : Visibility.Collapsed; + } + + private CronScheduleMode GetModeFromCombo() + { + return ModeComboBox.SelectedIndex == 1 ? CronScheduleMode.Weekly : CronScheduleMode.Daily; + } + + private void SetTime(int hour, int minute, int second) + { + HourComboBox.SelectedItem = hour.ToString("00"); + MinuteComboBox.SelectedItem = minute.ToString("00"); + SecondComboBox.SelectedItem = second.ToString("00"); + } + + private void ClearAllWeekdaySelection() + { + foreach (var checkBox in FindWeekdayCheckBoxes()) + { + checkBox.IsChecked = false; + } + } + + private void SetWeekdaySelection(IEnumerable selectedWeekDays) + { + var selectedSet = new HashSet(selectedWeekDays); + foreach (var checkBox in FindWeekdayCheckBoxes()) + { + var tag = checkBox.Tag?.ToString(); + checkBox.IsChecked = !string.IsNullOrWhiteSpace(tag) && selectedSet.Contains(tag); + } + } + + private List GetSelectedWeekDays() + { + var selected = new HashSet( + FindWeekdayCheckBoxes() + .Where(c => c.IsChecked == true) + .Select(c => c.Tag?.ToString()) + .Where(tag => !string.IsNullOrWhiteSpace(tag)) + .Cast()); + + return OrderedWeekDays.Where(selected.Contains).ToList(); + } + + private IEnumerable FindWeekdayCheckBoxes() + { + return WeeklyPanel.Children + .OfType() + .SelectMany(grid => grid.Children.OfType()); + } + + private static bool TryParseDaily(string cron, out int second, out int minute, out int hour) + { + second = 0; + minute = 0; + hour = 0; + if (!TrySplitQuartzCron(cron, out var parts)) + { + return false; + } + + var day = parts[3]; + var month = parts[4]; + var week = parts[5]; + + var isDaily = month == "*" + && ((day == "*" && week == "?") || (day == "?" && week == "*")); + + if (!isDaily) + { + return false; + } + + return ParseTime(parts[0], parts[1], parts[2], out second, out minute, out hour); + } + + private static bool TryParseWeekly(string cron, out int second, out int minute, out int hour, out List weekDays) + { + second = 0; + minute = 0; + hour = 0; + weekDays = new List(); + + if (!TrySplitQuartzCron(cron, out var parts)) + { + return false; + } + + if (parts[3] != "?" || parts[4] != "*") + { + return false; + } + + if (!ParseTime(parts[0], parts[1], parts[2], out second, out minute, out hour)) + { + return false; + } + + var days = parts[5] + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(NormalizeWeekDayToken) + .ToList(); + + if (days.Count == 0 || days.Any(string.IsNullOrWhiteSpace) || days.Any(d => !OrderedWeekDays.Contains(d!))) + { + return false; + } + + weekDays = days!.Cast().ToList(); + return true; + } + + private static bool TrySplitQuartzCron(string cron, out string[] parts) + { + parts = cron.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length is 6 or 7) + { + return true; + } + + parts = Array.Empty(); + return false; + } + + private static bool ParseTime(string secondRaw, string minuteRaw, string hourRaw, out int second, out int minute, out int hour) + { + second = 0; + minute = 0; + hour = 0; + + if (!int.TryParse(secondRaw, out second) + || !int.TryParse(minuteRaw, out minute) + || !int.TryParse(hourRaw, out hour)) + { + return false; + } + + return second is >= 0 and <= 59 + && minute is >= 0 and <= 59 + && hour is >= 0 and <= 23; + } + + private static string? NormalizeWeekDayToken(string token) + { + var normalized = token.Trim().ToUpperInvariant(); + + return normalized switch + { + "1" => "SUN", + "2" => "MON", + "3" => "TUE", + "4" => "WED", + "5" => "THU", + "6" => "FRI", + "7" => "SAT", + _ => normalized + }; + } + + private static string ToChineseWeekDays(IEnumerable weekDays) + { + return string.Join('、', weekDays.Select(day => day switch + { + "MON" => "一", + "TUE" => "二", + "WED" => "三", + "THU" => "四", + "FRI" => "五", + "SAT" => "六", + "SUN" => "日", + _ => day + })); + } +} diff --git a/BetterGenshinImpact/View/Windows/GearTask/AddTriggerDialog.xaml b/BetterGenshinImpact/View/Windows/GearTask/AddTriggerDialog.xaml index 69214257..f237558a 100644 --- a/BetterGenshinImpact/View/Windows/GearTask/AddTriggerDialog.xaml +++ b/BetterGenshinImpact/View/Windows/GearTask/AddTriggerDialog.xaml @@ -7,6 +7,7 @@ xmlns:gearTask="clr-namespace:BetterGenshinImpact.ViewModel.Windows.GearTask" xmlns:converters="clr-namespace:BetterGenshinImpact.View.Converters" xmlns:hotKey="clr-namespace:BetterGenshinImpact.View.Controls.HotKey" + xmlns:controls="clr-namespace:BetterGenshinImpact.View.Controls" d:DataContext="{d:DesignInstance Type=gearTask:AddTriggerDialogViewModel}" Width="500" Height="450" @@ -65,11 +66,25 @@ - - - + + + + + + + + + + + diff --git a/BetterGenshinImpact/ViewModel/Windows/GearTask/AddTriggerDialogViewModel.cs b/BetterGenshinImpact/ViewModel/Windows/GearTask/AddTriggerDialogViewModel.cs index 9a11eeac..e0019735 100644 --- a/BetterGenshinImpact/ViewModel/Windows/GearTask/AddTriggerDialogViewModel.cs +++ b/BetterGenshinImpact/ViewModel/Windows/GearTask/AddTriggerDialogViewModel.cs @@ -10,6 +10,7 @@ using BetterGenshinImpact.Service; using Microsoft.Extensions.Logging; using Wpf.Ui.Violeta.Controls; using BetterGenshinImpact.Helpers.Extensions; +using System.ComponentModel; namespace BetterGenshinImpact.ViewModel.Windows.GearTask; @@ -31,7 +32,10 @@ public partial class AddTriggerDialogViewModel : ObservableObject private TriggerType _selectedTriggerType = TriggerType.Timed; [ObservableProperty] - private string _cronExpression = "0 0 8 * * ?"; // 默认每天8点 + private string _cronExpression = "1 0 4 * * ?"; // 默认每天 04:00:01 + + [ObservableProperty] + private CronInputMode _selectedCronInputMode = CronInputMode.Preset; [ObservableProperty] private HotKey? _selectedHotkey; @@ -65,6 +69,15 @@ public partial class AddTriggerDialogViewModel : ObservableObject EnumItem.Create(TriggerType.Hotkey) }; + /// + /// Cron 输入模式 + /// + public ObservableCollection> CronInputModes { get; } = new() + { + EnumItem.Create(CronInputMode.Preset), + EnumItem.Create(CronInputMode.Manual) + }; + /// /// 可用的任务定义列表 /// @@ -131,6 +144,9 @@ public partial class AddTriggerDialogViewModel : ObservableObject CronExpression = existingTrigger.TriggerType == TriggerType.Timed ? (existingTrigger.CronExpression ?? CronExpression) : CronExpression; + SelectedCronInputMode = existingTrigger.TriggerType == TriggerType.Timed + ? CronInputMode.Manual + : CronInputMode.Preset; SelectedHotkey = existingTrigger.TriggerType == TriggerType.Hotkey ? existingTrigger.Hotkey @@ -211,6 +227,19 @@ public partial class AddTriggerDialogViewModel : ObservableObject GenerateDefaultName(); } + partial void OnSelectedCronInputModeChanged(CronInputMode value) + { + if (SelectedTriggerType != TriggerType.Timed) + { + return; + } + + if (string.IsNullOrWhiteSpace(CronExpression)) + { + CronExpression = "1 0 4 * * ?"; + } + } + /// /// 确认创建触发器 /// @@ -225,7 +254,9 @@ public partial class AddTriggerDialogViewModel : ObservableObject if (SelectedTriggerType == TriggerType.Timed && string.IsNullOrWhiteSpace(CronExpression)) { - Toast.Error("请输入 Cron 表达式"); + Toast.Error(SelectedCronInputMode == CronInputMode.Manual + ? "请输入 Cron 表达式" + : "请先完成定时选择"); return; } @@ -276,3 +307,11 @@ public partial class AddTriggerDialogViewModel : ObservableObject // HotKeyTextBox会直接绑定到SelectedHotkey属性 } } + +public enum CronInputMode +{ + [Description("可视化选择")] + Preset, + [Description("手动 Cron")] + Manual +}