feat(app): 初始化 OmniScheduler WPF 调度器
基于 PRD 搭建 .NET 8 WPF 桌面应用,包含主控制台、任务编辑、全局设置、系统托盘和应用图标集成。 实现本地任务模型、触发器规则、JSON 状态持久化、OmniNotify HTTP 推送、执行日志记录、动态变量替换以及基础 Cron 预览能力。 补充 .gitignore,排除构建产物和本地 IDE 文件。 BREAKING CHANGE: 首次提交,建立项目初始结构
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
**/bin/
|
||||
**/obj/
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
9
OmniScheduler/App.xaml
Normal file
9
OmniScheduler/App.xaml
Normal file
@@ -0,0 +1,9 @@
|
||||
<Application x:Class="OmniScheduler.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:OmniScheduler"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
5
OmniScheduler/App.xaml.cs
Normal file
5
OmniScheduler/App.xaml.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace OmniScheduler;
|
||||
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
}
|
||||
10
OmniScheduler/AssemblyInfo.cs
Normal file
10
OmniScheduler/AssemblyInfo.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
[assembly:ThemeInfo(
|
||||
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
|
||||
//(used if a resource is not found in the page,
|
||||
// or application resource dictionaries)
|
||||
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
|
||||
//(used if a resource is not found in the page,
|
||||
// app, or any theme specific resource dictionaries)
|
||||
)]
|
||||
103
OmniScheduler/MainWindow.xaml
Normal file
103
OmniScheduler/MainWindow.xaml
Normal file
@@ -0,0 +1,103 @@
|
||||
<Window x:Class="OmniScheduler.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="OmniScheduler"
|
||||
Height="780"
|
||||
Width="1180"
|
||||
MinHeight="640"
|
||||
MinWidth="980"
|
||||
Icon="app.ico"
|
||||
Closing="Window_Closing">
|
||||
<Window.Resources>
|
||||
<SolidColorBrush x:Key="PanelBrush" Color="#F5F7FA" />
|
||||
<SolidColorBrush x:Key="LineBrush" Color="#D8DEE9" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#2563EB" />
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="MinHeight" Value="30" />
|
||||
<Setter Property="Padding" Value="12,4" />
|
||||
<Setter Property="Margin" Value="0,0,8,0" />
|
||||
</Style>
|
||||
<Style TargetType="DataGrid">
|
||||
<Setter Property="AutoGenerateColumns" Value="False" />
|
||||
<Setter Property="CanUserAddRows" Value="False" />
|
||||
<Setter Property="IsReadOnly" Value="True" />
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
<Setter Property="RowHeight" Value="32" />
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<DockPanel Background="White">
|
||||
<Border DockPanel.Dock="Top" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="0,0,0,1" Padding="10,8">
|
||||
<DockPanel>
|
||||
<TextBlock DockPanel.Dock="Right" VerticalAlignment="Center" Foreground="#667085" Text="{Binding StatusText}" />
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Content="新建" Click="AddTask_Click" />
|
||||
<Button Content="编辑" Click="EditTask_Click" />
|
||||
<Button Content="删除" Click="DeleteTask_Click" />
|
||||
<Button Content="克隆" Click="CloneTask_Click" />
|
||||
<Button Content="手动触发" Click="FireTask_Click" />
|
||||
<Button Content="设置" Click="Settings_Click" />
|
||||
<ToggleButton x:Name="PauseToggle" Content="暂停全部" Checked="PauseToggle_Changed" Unchecked="PauseToggle_Changed" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="3*" />
|
||||
<RowDefinition Height="6" />
|
||||
<RowDefinition Height="2*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DataGrid x:Name="TasksGrid"
|
||||
Grid.Row="0"
|
||||
ItemsSource="{Binding State.Tasks}"
|
||||
SelectedItem="{Binding SelectedTask, Mode=TwoWay}"
|
||||
MouseDoubleClick="TasksGrid_MouseDoubleClick">
|
||||
<DataGrid.Columns>
|
||||
<DataGridCheckBoxColumn Header="启用" Binding="{Binding IsEnabled}" Width="58" IsReadOnly="False" />
|
||||
<DataGridTextColumn Header="任务名称" Binding="{Binding Name}" Width="190" />
|
||||
<DataGridTextColumn Header="频道" Binding="{Binding Channel}" Width="150" />
|
||||
<DataGridTextColumn Header="触发规则" Binding="{Binding TriggerSummary}" Width="*" />
|
||||
<DataGridTextColumn Header="上次运行" Binding="{Binding LastRunAt, StringFormat=yyyy-MM-dd HH:mm:ss}" Width="155" />
|
||||
<DataGridTextColumn Header="下次运行" Binding="{Binding NextRunAt, StringFormat=yyyy-MM-dd HH:mm:ss}" Width="155" />
|
||||
<DataGridTextColumn Header="最近状态" Binding="{Binding LastStatus}" Width="90" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<GridSplitter Grid.Row="1" Height="6" HorizontalAlignment="Stretch" Background="{StaticResource LineBrush}" />
|
||||
|
||||
<DockPanel Grid.Row="2">
|
||||
<Border DockPanel.Dock="Top" Background="{StaticResource PanelBrush}" BorderBrush="{StaticResource LineBrush}" BorderThickness="0,1,0,1" Padding="10,6">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="清空日志" Click="ClearLogs_Click" />
|
||||
<TextBlock FontWeight="SemiBold" VerticalAlignment="Center" Text="执行日志" />
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<DataGrid x:Name="LogsGrid"
|
||||
ItemsSource="{Binding State.Logs}"
|
||||
MouseDoubleClick="LogsGrid_MouseDoubleClick">
|
||||
<DataGrid.RowStyle>
|
||||
<Style TargetType="DataGridRow">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Level}" Value="ERROR">
|
||||
<Setter Property="Foreground" Value="#B42318" />
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</DataGrid.RowStyle>
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="时间" Binding="{Binding Timestamp, StringFormat=yyyy-MM-dd HH:mm:ss}" Width="155" />
|
||||
<DataGridTextColumn Header="级别" Binding="{Binding Level}" Width="70" />
|
||||
<DataGridTextColumn Header="任务" Binding="{Binding TaskName}" Width="170" />
|
||||
<DataGridTextColumn Header="触发" Binding="{Binding TriggerType}" Width="100" />
|
||||
<DataGridTextColumn Header="HTTP" Binding="{Binding StatusCode}" Width="70" />
|
||||
<DataGridTextColumn Header="耗时(ms)" Binding="{Binding ElapsedMs}" Width="80" />
|
||||
<DataGridTextColumn Header="消息" Binding="{Binding Message}" Width="*" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
225
OmniScheduler/MainWindow.xaml.cs
Normal file
225
OmniScheduler/MainWindow.xaml.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
using Forms = System.Windows.Forms;
|
||||
|
||||
namespace OmniScheduler;
|
||||
|
||||
public partial class MainWindow : Window, INotifyPropertyChanged
|
||||
{
|
||||
private readonly StateStore _store = new();
|
||||
private readonly NotifyClient _client = new();
|
||||
private readonly SchedulerService _scheduler;
|
||||
private readonly Forms.NotifyIcon _trayIcon;
|
||||
private bool _allowExit;
|
||||
private ScheduledTask? _selectedTask;
|
||||
private string _statusText = "就绪";
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
State = _store.Load();
|
||||
_scheduler = new SchedulerService(State, _client, AddLog);
|
||||
_trayIcon = BuildTrayIcon();
|
||||
DataContext = this;
|
||||
_scheduler.Start();
|
||||
|
||||
if (Environment.GetCommandLineArgs().Any(a => a.Equals("--tray", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
|
||||
public SchedulerState State { get; }
|
||||
|
||||
public ScheduledTask? SelectedTask
|
||||
{
|
||||
get => _selectedTask;
|
||||
set
|
||||
{
|
||||
_selectedTask = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedTask)));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get => _statusText;
|
||||
set
|
||||
{
|
||||
_statusText = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StatusText)));
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private Forms.NotifyIcon BuildTrayIcon()
|
||||
{
|
||||
var menu = new Forms.ContextMenuStrip();
|
||||
menu.Items.Add("打开主界面", null, (_, _) => ShowDashboard());
|
||||
menu.Items.Add("暂停全部任务", null, (_, _) =>
|
||||
{
|
||||
_scheduler.IsPaused = !_scheduler.IsPaused;
|
||||
PauseToggle.IsChecked = _scheduler.IsPaused;
|
||||
});
|
||||
menu.Items.Add("退出", null, (_, _) => ExitApplication());
|
||||
|
||||
var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "app.ico");
|
||||
var icon = new Forms.NotifyIcon
|
||||
{
|
||||
Icon = new System.Drawing.Icon(iconPath),
|
||||
Text = "OmniScheduler",
|
||||
Visible = true,
|
||||
ContextMenuStrip = menu
|
||||
};
|
||||
icon.DoubleClick += (_, _) => ShowDashboard();
|
||||
return icon;
|
||||
}
|
||||
|
||||
private void AddTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var task = new ScheduledTask();
|
||||
task.Triggers.Add(new TaskTrigger());
|
||||
if (TaskEditorWindow.Edit(this, task, State.Settings, _client))
|
||||
{
|
||||
State.Tasks.Add(task);
|
||||
SaveAndRefresh("已创建任务");
|
||||
}
|
||||
}
|
||||
|
||||
private void EditTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedTask is null)
|
||||
{
|
||||
StatusText = "请先选择一个任务";
|
||||
return;
|
||||
}
|
||||
|
||||
if (TaskEditorWindow.Edit(this, SelectedTask, State.Settings, _client))
|
||||
{
|
||||
SaveAndRefresh("任务已保存");
|
||||
}
|
||||
}
|
||||
|
||||
private void TasksGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
=> EditTask_Click(sender, e);
|
||||
|
||||
private void DeleteTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedTask is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (System.Windows.MessageBox.Show(this, $"删除任务“{SelectedTask.Name}”?", "确认删除", MessageBoxButton.YesNo, MessageBoxImage.Warning) == MessageBoxResult.Yes)
|
||||
{
|
||||
State.Tasks.Remove(SelectedTask);
|
||||
SaveAndRefresh("任务已删除");
|
||||
}
|
||||
}
|
||||
|
||||
private void CloneTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedTask is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var clone = SelectedTask.Clone();
|
||||
State.Tasks.Add(clone);
|
||||
SelectedTask = clone;
|
||||
SaveAndRefresh("任务已克隆");
|
||||
}
|
||||
|
||||
private async void FireTask_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SelectedTask is null)
|
||||
{
|
||||
StatusText = "请先选择一个任务";
|
||||
return;
|
||||
}
|
||||
|
||||
StatusText = "正在手动触发...";
|
||||
await _scheduler.FireNowAsync(SelectedTask);
|
||||
SaveAndRefresh("手动触发完成");
|
||||
}
|
||||
|
||||
private void Settings_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SettingsWindow.Edit(this, State.Settings))
|
||||
{
|
||||
StartupRegistration.Apply(State.Settings.StartWithWindows);
|
||||
SaveAndRefresh("设置已保存");
|
||||
}
|
||||
}
|
||||
|
||||
private void PauseToggle_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_scheduler.IsPaused = PauseToggle.IsChecked == true;
|
||||
StatusText = _scheduler.IsPaused ? "全部任务已暂停" : "调度已恢复";
|
||||
}
|
||||
|
||||
private void ClearLogs_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
State.Logs.Clear();
|
||||
SaveAndRefresh("日志已清空");
|
||||
}
|
||||
|
||||
private void LogsGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (LogsGrid.SelectedItem is ExecutionLog log)
|
||||
{
|
||||
System.Windows.MessageBox.Show(this, $"请求 JSON:\n{log.RequestJson}\n\n响应:\n{log.Response}", "日志详情", MessageBoxButton.OK, log.Level == "ERROR" ? MessageBoxImage.Error : MessageBoxImage.Information);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddLog(ExecutionLog log)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
{
|
||||
State.Logs.Insert(0, log);
|
||||
StatusText = $"{log.Timestamp:HH:mm:ss} {log.TaskName}: {log.Message}";
|
||||
_store.Save(State);
|
||||
});
|
||||
}
|
||||
|
||||
private void SaveAndRefresh(string status)
|
||||
{
|
||||
_scheduler.RecalculateNextRuns();
|
||||
foreach (var task in State.Tasks)
|
||||
{
|
||||
task.Notify(nameof(ScheduledTask.TriggerSummary));
|
||||
}
|
||||
TasksGrid.Items.Refresh();
|
||||
_store.Save(State);
|
||||
StatusText = status;
|
||||
}
|
||||
|
||||
private void Window_Closing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
if (_allowExit)
|
||||
{
|
||||
_trayIcon.Visible = false;
|
||||
_trayIcon.Dispose();
|
||||
_store.Save(State);
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
StatusText = "已隐藏到系统托盘";
|
||||
}
|
||||
|
||||
private void ShowDashboard()
|
||||
{
|
||||
Show();
|
||||
WindowState = WindowState.Normal;
|
||||
Activate();
|
||||
}
|
||||
|
||||
private void ExitApplication()
|
||||
{
|
||||
_allowExit = true;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
281
OmniScheduler/Models.cs
Normal file
281
OmniScheduler/Models.cs
Normal file
@@ -0,0 +1,281 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace OmniScheduler;
|
||||
|
||||
public enum TriggerKind
|
||||
{
|
||||
OneTime,
|
||||
Interval,
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Cron
|
||||
}
|
||||
|
||||
public enum IntervalUnit
|
||||
{
|
||||
Seconds,
|
||||
Minutes,
|
||||
Hours,
|
||||
Days
|
||||
}
|
||||
|
||||
public enum MisfireStrategy
|
||||
{
|
||||
DoNothing,
|
||||
FireOnceNow
|
||||
}
|
||||
|
||||
public sealed class SchedulerState : INotifyPropertyChanged
|
||||
{
|
||||
public ObservableCollection<ScheduledTask> Tasks { get; set; } = [];
|
||||
public ObservableCollection<ExecutionLog> Logs { get; set; } = [];
|
||||
public AppSettings Settings { get; set; } = new();
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void Notify([CallerMemberName] string? propertyName = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
|
||||
public sealed class AppSettings : NotifyObject
|
||||
{
|
||||
private string _omniNotifyUrl = "http://127.0.0.1:19845/notify";
|
||||
private int _logRetentionDays = 30;
|
||||
private int _maxLogRecords = 10000;
|
||||
private bool _startWithWindows;
|
||||
|
||||
public string OmniNotifyUrl
|
||||
{
|
||||
get => _omniNotifyUrl;
|
||||
set => SetField(ref _omniNotifyUrl, value);
|
||||
}
|
||||
|
||||
public int LogRetentionDays
|
||||
{
|
||||
get => _logRetentionDays;
|
||||
set => SetField(ref _logRetentionDays, Math.Max(1, value));
|
||||
}
|
||||
|
||||
public int MaxLogRecords
|
||||
{
|
||||
get => _maxLogRecords;
|
||||
set => SetField(ref _maxLogRecords, Math.Max(100, value));
|
||||
}
|
||||
|
||||
public bool StartWithWindows
|
||||
{
|
||||
get => _startWithWindows;
|
||||
set => SetField(ref _startWithWindows, value);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ScheduledTask : NotifyObject
|
||||
{
|
||||
private bool _isEnabled = true;
|
||||
private string _name = "新任务";
|
||||
private string _description = "";
|
||||
private string _channel = "";
|
||||
private string _title = "";
|
||||
private string _body = "";
|
||||
private DateTime? _lastRunAt;
|
||||
private DateTime? _nextRunAt;
|
||||
private string _lastStatus = "Idle";
|
||||
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => SetField(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set => SetField(ref _name, value);
|
||||
}
|
||||
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set => SetField(ref _description, value);
|
||||
}
|
||||
|
||||
public string Channel
|
||||
{
|
||||
get => _channel;
|
||||
set => SetField(ref _channel, value);
|
||||
}
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => _title;
|
||||
set => SetField(ref _title, value);
|
||||
}
|
||||
|
||||
public string Body
|
||||
{
|
||||
get => _body;
|
||||
set => SetField(ref _body, value);
|
||||
}
|
||||
|
||||
public MisfireStrategy MisfireStrategy { get; set; } = MisfireStrategy.DoNothing;
|
||||
public ObservableCollection<TaskTrigger> Triggers { get; set; } = [];
|
||||
|
||||
public DateTime? LastRunAt
|
||||
{
|
||||
get => _lastRunAt;
|
||||
set => SetField(ref _lastRunAt, value);
|
||||
}
|
||||
|
||||
public DateTime? NextRunAt
|
||||
{
|
||||
get => _nextRunAt;
|
||||
set => SetField(ref _nextRunAt, value);
|
||||
}
|
||||
|
||||
public string LastStatus
|
||||
{
|
||||
get => _lastStatus;
|
||||
set => SetField(ref _lastStatus, value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public string TriggerSummary => Triggers.Count == 0
|
||||
? "未配置触发器"
|
||||
: string.Join("; ", Triggers.Select(t => t.Summary).Take(3));
|
||||
|
||||
public ScheduledTask Clone()
|
||||
{
|
||||
return new ScheduledTask
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IsEnabled = IsEnabled,
|
||||
Name = $"{Name} - 副本",
|
||||
Description = Description,
|
||||
Channel = Channel,
|
||||
Title = Title,
|
||||
Body = Body,
|
||||
MisfireStrategy = MisfireStrategy,
|
||||
Triggers = new ObservableCollection<TaskTrigger>(Triggers.Select(t => t.Clone()))
|
||||
};
|
||||
}
|
||||
|
||||
public void CopyFrom(ScheduledTask source)
|
||||
{
|
||||
IsEnabled = source.IsEnabled;
|
||||
Name = source.Name;
|
||||
Description = source.Description;
|
||||
Channel = source.Channel;
|
||||
Title = source.Title;
|
||||
Body = source.Body;
|
||||
MisfireStrategy = source.MisfireStrategy;
|
||||
Triggers = new ObservableCollection<TaskTrigger>(source.Triggers.Select(t => t.Clone()));
|
||||
Notify(nameof(TriggerSummary));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TaskTrigger : NotifyObject
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public TriggerKind Kind { get; set; } = TriggerKind.Interval;
|
||||
public DateTime OneTimeAt { get; set; } = DateTime.Now.AddMinutes(5);
|
||||
public int IntervalValue { get; set; } = 5;
|
||||
public IntervalUnit IntervalUnit { get; set; } = IntervalUnit.Minutes;
|
||||
public DateTime? StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public TimeSpan TimeOfDay { get; set; } = DateTime.Now.TimeOfDay;
|
||||
public DayOfWeekFlags WeekDays { get; set; } = DayOfWeekFlags.Monday | DayOfWeekFlags.Tuesday | DayOfWeekFlags.Wednesday | DayOfWeekFlags.Thursday | DayOfWeekFlags.Friday;
|
||||
public int MonthDay { get; set; } = 1;
|
||||
public bool LastBusinessDay { get; set; }
|
||||
public string CronExpression { get; set; } = "0 0/5 14 * * ?";
|
||||
|
||||
[JsonIgnore]
|
||||
public string Summary => Kind switch
|
||||
{
|
||||
TriggerKind.OneTime => $"单次 {OneTimeAt:yyyy-MM-dd HH:mm:ss}",
|
||||
TriggerKind.Interval => $"每 {IntervalValue} {IntervalUnitText}",
|
||||
TriggerKind.Daily => $"每日 {TimeOfDay:hh\\:mm\\:ss}",
|
||||
TriggerKind.Weekly => $"每周 {WeekDays} {TimeOfDay:hh\\:mm\\:ss}",
|
||||
TriggerKind.Monthly => LastBusinessDay ? $"每月最后工作日 {TimeOfDay:hh\\:mm\\:ss}" : $"每月 {MonthDay} 日 {TimeOfDay:hh\\:mm\\:ss}",
|
||||
TriggerKind.Cron => $"Cron {CronExpression}",
|
||||
_ => Kind.ToString()
|
||||
};
|
||||
|
||||
[JsonIgnore]
|
||||
public string IntervalUnitText => IntervalUnit switch
|
||||
{
|
||||
IntervalUnit.Seconds => "秒",
|
||||
IntervalUnit.Minutes => "分钟",
|
||||
IntervalUnit.Hours => "小时",
|
||||
IntervalUnit.Days => "天",
|
||||
_ => IntervalUnit.ToString()
|
||||
};
|
||||
|
||||
public TaskTrigger Clone() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Kind = Kind,
|
||||
OneTimeAt = OneTimeAt,
|
||||
IntervalValue = IntervalValue,
|
||||
IntervalUnit = IntervalUnit,
|
||||
StartsAt = StartsAt,
|
||||
EndsAt = EndsAt,
|
||||
TimeOfDay = TimeOfDay,
|
||||
WeekDays = WeekDays,
|
||||
MonthDay = MonthDay,
|
||||
LastBusinessDay = LastBusinessDay,
|
||||
CronExpression = CronExpression
|
||||
};
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum DayOfWeekFlags
|
||||
{
|
||||
None = 0,
|
||||
Sunday = 1,
|
||||
Monday = 2,
|
||||
Tuesday = 4,
|
||||
Wednesday = 8,
|
||||
Thursday = 16,
|
||||
Friday = 32,
|
||||
Saturday = 64
|
||||
}
|
||||
|
||||
public sealed class ExecutionLog
|
||||
{
|
||||
public DateTime Timestamp { get; set; } = DateTime.Now;
|
||||
public string Level { get; set; } = "INFO";
|
||||
public string TaskName { get; set; } = "";
|
||||
public string TriggerType { get; set; } = "";
|
||||
public string TargetUrl { get; set; } = "";
|
||||
public int? StatusCode { get; set; }
|
||||
public long ElapsedMs { get; set; }
|
||||
public string RequestJson { get; set; } = "";
|
||||
public string Response { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
public abstract class NotifyObject : INotifyPropertyChanged
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Notify([CallerMemberName] string? propertyName = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
17
OmniScheduler/OmniScheduler.csproj
Normal file
17
OmniScheduler/OmniScheduler.csproj
Normal file
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>..\app.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\app.ico" Link="app.ico" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
449
OmniScheduler/Services.cs
Normal file
449
OmniScheduler/Services.cs
Normal file
@@ -0,0 +1,449 @@
|
||||
using Microsoft.Win32;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace OmniScheduler;
|
||||
|
||||
public sealed class StateStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _statePath;
|
||||
|
||||
public StateStore()
|
||||
{
|
||||
var dir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniScheduler");
|
||||
Directory.CreateDirectory(dir);
|
||||
_statePath = Path.Combine(dir, "state.json");
|
||||
}
|
||||
|
||||
public SchedulerState Load()
|
||||
{
|
||||
if (!File.Exists(_statePath))
|
||||
{
|
||||
return Seed();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = JsonSerializer.Deserialize<SchedulerState>(File.ReadAllText(_statePath), JsonOptions);
|
||||
return state ?? Seed();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Seed();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(SchedulerState state)
|
||||
{
|
||||
TrimLogs(state);
|
||||
File.WriteAllText(_statePath, JsonSerializer.Serialize(state, JsonOptions), Encoding.UTF8);
|
||||
}
|
||||
|
||||
private static SchedulerState Seed()
|
||||
{
|
||||
var task = new ScheduledTask
|
||||
{
|
||||
Name = "OmniNotify 连通性测试",
|
||||
Description = "可克隆此任务作为新消息模板。",
|
||||
Channel = "default",
|
||||
Title = "OmniScheduler 测试",
|
||||
Body = "来自 {TaskName} 的测试消息,触发时间 {CurrentTime}。"
|
||||
};
|
||||
task.Triggers.Add(new TaskTrigger { Kind = TriggerKind.Interval, IntervalValue = 30, IntervalUnit = IntervalUnit.Minutes });
|
||||
return new SchedulerState { Tasks = new ObservableCollection<ScheduledTask> { task } };
|
||||
}
|
||||
|
||||
private static void TrimLogs(SchedulerState state)
|
||||
{
|
||||
var cutoff = DateTime.Now.AddDays(-state.Settings.LogRetentionDays);
|
||||
var kept = state.Logs
|
||||
.Where(l => l.Timestamp >= cutoff)
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.Take(state.Settings.MaxLogRecords)
|
||||
.OrderByDescending(l => l.Timestamp)
|
||||
.ToList();
|
||||
|
||||
state.Logs.Clear();
|
||||
foreach (var log in kept)
|
||||
{
|
||||
state.Logs.Add(log);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NotifyClient
|
||||
{
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
|
||||
|
||||
public async Task<ExecutionLog> SendAsync(ScheduledTask task, string triggerType, string targetUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
channel = Expand(task.Channel, task, triggerType),
|
||||
title = Expand(task.Title, task, triggerType),
|
||||
body = Expand(task.Body, task, triggerType)
|
||||
};
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var sw = Stopwatch.StartNew();
|
||||
var log = new ExecutionLog
|
||||
{
|
||||
Timestamp = DateTime.Now,
|
||||
TaskName = task.Name,
|
||||
TriggerType = triggerType,
|
||||
TargetUrl = targetUrl,
|
||||
RequestJson = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true })
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var response = await _http.PostAsync(targetUrl, content, cancellationToken);
|
||||
log.StatusCode = (int)response.StatusCode;
|
||||
log.Response = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
log.Level = response.IsSuccessStatusCode ? "INFO" : "ERROR";
|
||||
log.Message = response.IsSuccessStatusCode ? "发送成功" : BuildErrorMessage(log.Response, response.StatusCode.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log.Level = "ERROR";
|
||||
log.Response = ex.Message;
|
||||
log.Message = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
log.ElapsedMs = sw.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
private static string Expand(string value, ScheduledTask task, string triggerType)
|
||||
=> value
|
||||
.Replace("{CurrentTime}", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{TaskName}", task.Name, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{TriggerType}", triggerType, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string BuildErrorMessage(string response, string fallback)
|
||||
=> response.Contains("IllegalChannel", StringComparison.OrdinalIgnoreCase)
|
||||
? "ERROR: IllegalChannel,请检查频道名配置"
|
||||
: $"发送失败: {fallback}";
|
||||
}
|
||||
|
||||
public sealed class SchedulerService
|
||||
{
|
||||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromSeconds(1) };
|
||||
private readonly SchedulerState _state;
|
||||
private readonly NotifyClient _client;
|
||||
private readonly Action<ExecutionLog> _logSink;
|
||||
private bool _paused;
|
||||
|
||||
public SchedulerService(SchedulerState state, NotifyClient client, Action<ExecutionLog> logSink)
|
||||
{
|
||||
_state = state;
|
||||
_client = client;
|
||||
_logSink = logSink;
|
||||
_timer.Tick += async (_, _) => await TickAsync();
|
||||
}
|
||||
|
||||
public bool IsPaused
|
||||
{
|
||||
get => _paused;
|
||||
set => _paused = value;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
RecalculateNextRuns();
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void RecalculateNextRuns()
|
||||
{
|
||||
foreach (var task in _state.Tasks)
|
||||
{
|
||||
task.NextRunAt = task.IsEnabled ? FindNext(task, DateTime.Now) : null;
|
||||
task.Notify(nameof(ScheduledTask.TriggerSummary));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task FireNowAsync(ScheduledTask task)
|
||||
{
|
||||
await ExecuteAsync(task, "Manual");
|
||||
task.NextRunAt = FindNext(task, DateTime.Now);
|
||||
}
|
||||
|
||||
private async Task TickAsync()
|
||||
{
|
||||
if (_paused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.Now;
|
||||
var due = _state.Tasks
|
||||
.Where(t => t.IsEnabled && t.NextRunAt.HasValue && t.NextRunAt.Value <= now)
|
||||
.ToList();
|
||||
|
||||
await Task.WhenAll(due.Select(async task =>
|
||||
{
|
||||
var scheduled = task.NextRunAt!.Value;
|
||||
if (now - scheduled > TimeSpan.FromSeconds(30) && task.MisfireStrategy == MisfireStrategy.DoNothing)
|
||||
{
|
||||
task.NextRunAt = FindNext(task, now);
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteAsync(task, PickTriggerSummary(task, scheduled));
|
||||
task.NextRunAt = FindNext(task, now.AddSeconds(1));
|
||||
}));
|
||||
}
|
||||
|
||||
private async Task ExecuteAsync(ScheduledTask task, string triggerType)
|
||||
{
|
||||
task.LastRunAt = DateTime.Now;
|
||||
var log = await _client.SendAsync(task, triggerType, _state.Settings.OmniNotifyUrl);
|
||||
task.LastStatus = log.Level == "INFO" ? "Success" : "Failed";
|
||||
_logSink(log);
|
||||
}
|
||||
|
||||
private static string PickTriggerSummary(ScheduledTask task, DateTime dueAt)
|
||||
{
|
||||
var trigger = task.Triggers
|
||||
.OrderBy(t => Math.Abs(((NextRunCalculator.NextRun(t, DateTime.Now.AddDays(-1)) ?? dueAt) - dueAt).TotalSeconds))
|
||||
.FirstOrDefault();
|
||||
return trigger?.Kind.ToString() ?? "Scheduled";
|
||||
}
|
||||
|
||||
private static DateTime? FindNext(ScheduledTask task, DateTime from)
|
||||
{
|
||||
return task.Triggers
|
||||
.Select(t => NextRunCalculator.NextRun(t, from))
|
||||
.Where(d => d.HasValue)
|
||||
.Min();
|
||||
}
|
||||
}
|
||||
|
||||
public static class NextRunCalculator
|
||||
{
|
||||
public static DateTime? NextRun(TaskTrigger trigger, DateTime from)
|
||||
{
|
||||
if (trigger.EndsAt.HasValue && from > trigger.EndsAt.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var earliest = trigger.StartsAt.HasValue && trigger.StartsAt.Value > from ? trigger.StartsAt.Value : from;
|
||||
DateTime? next = trigger.Kind switch
|
||||
{
|
||||
TriggerKind.OneTime => trigger.OneTimeAt > from ? trigger.OneTimeAt : null,
|
||||
TriggerKind.Interval => NextInterval(trigger, earliest),
|
||||
TriggerKind.Daily => NextDaily(trigger.TimeOfDay, earliest),
|
||||
TriggerKind.Weekly => NextWeekly(trigger, earliest),
|
||||
TriggerKind.Monthly => NextMonthly(trigger, earliest),
|
||||
TriggerKind.Cron => NextCron(trigger.CronExpression, earliest),
|
||||
_ => null
|
||||
};
|
||||
|
||||
return trigger.EndsAt.HasValue && next > trigger.EndsAt.Value ? null : next;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<DateTime> Preview(TaskTrigger trigger, DateTime from, int count = 5)
|
||||
{
|
||||
var results = new List<DateTime>();
|
||||
var cursor = from;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var next = NextRun(trigger, cursor);
|
||||
if (!next.HasValue)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
results.Add(next.Value);
|
||||
cursor = next.Value.AddSeconds(1);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static DateTime? NextInterval(TaskTrigger trigger, DateTime from)
|
||||
{
|
||||
if (trigger.IntervalValue < 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var interval = trigger.IntervalUnit switch
|
||||
{
|
||||
IntervalUnit.Seconds => TimeSpan.FromSeconds(trigger.IntervalValue),
|
||||
IntervalUnit.Minutes => TimeSpan.FromMinutes(trigger.IntervalValue),
|
||||
IntervalUnit.Hours => TimeSpan.FromHours(trigger.IntervalValue),
|
||||
IntervalUnit.Days => TimeSpan.FromDays(trigger.IntervalValue),
|
||||
_ => TimeSpan.FromMinutes(trigger.IntervalValue)
|
||||
};
|
||||
|
||||
var start = trigger.StartsAt ?? DateTime.Now;
|
||||
if (from <= start)
|
||||
{
|
||||
return start;
|
||||
}
|
||||
|
||||
var steps = Math.Ceiling((from - start).TotalSeconds / interval.TotalSeconds);
|
||||
return start.AddSeconds(steps * interval.TotalSeconds);
|
||||
}
|
||||
|
||||
private static DateTime NextDaily(TimeSpan time, DateTime from)
|
||||
{
|
||||
var candidate = from.Date.Add(time);
|
||||
return candidate > from ? candidate : candidate.AddDays(1);
|
||||
}
|
||||
|
||||
private static DateTime NextWeekly(TaskTrigger trigger, DateTime from)
|
||||
{
|
||||
for (var i = 0; i < 14; i++)
|
||||
{
|
||||
var day = from.Date.AddDays(i);
|
||||
if (HasDay(trigger.WeekDays, day.DayOfWeek))
|
||||
{
|
||||
var candidate = day.Add(trigger.TimeOfDay);
|
||||
if (candidate > from)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return from.AddDays(7);
|
||||
}
|
||||
|
||||
private static DateTime? NextMonthly(TaskTrigger trigger, DateTime from)
|
||||
{
|
||||
for (var i = 0; i < 24; i++)
|
||||
{
|
||||
var month = new DateTime(from.Year, from.Month, 1).AddMonths(i);
|
||||
var day = trigger.LastBusinessDay
|
||||
? LastBusinessDay(month.Year, month.Month)
|
||||
: Math.Min(Math.Max(trigger.MonthDay, 1), DateTime.DaysInMonth(month.Year, month.Month));
|
||||
var candidate = new DateTime(month.Year, month.Month, day).Add(trigger.TimeOfDay);
|
||||
if (candidate > from)
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? NextCron(string expression, DateTime from)
|
||||
{
|
||||
var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 6)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
for (var probe = from.AddSeconds(1); probe < from.AddDays(366); probe = probe.AddSeconds(1))
|
||||
{
|
||||
if (Matches(parts[0], probe.Second, 0, 59) &&
|
||||
Matches(parts[1], probe.Minute, 0, 59) &&
|
||||
Matches(parts[2], probe.Hour, 0, 23) &&
|
||||
Matches(parts[3], probe.Day, 1, 31) &&
|
||||
Matches(parts[4], probe.Month, 1, 12) &&
|
||||
(parts[5] == "?" || Matches(parts[5], (int)probe.DayOfWeek, 0, 6)))
|
||||
{
|
||||
return probe;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool Matches(string field, int value, int min, int max)
|
||||
{
|
||||
if (field is "*" or "?")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return field.Split(',').Any(part =>
|
||||
{
|
||||
if (part.Contains('/'))
|
||||
{
|
||||
var pieces = part.Split('/');
|
||||
var step = int.TryParse(pieces[1], out var parsedStep) ? parsedStep : 1;
|
||||
var start = pieces[0] == "*" ? min : int.TryParse(pieces[0], out var parsedStart) ? parsedStart : min;
|
||||
return value >= start && (value - start) % step == 0;
|
||||
}
|
||||
|
||||
if (part.Contains('-'))
|
||||
{
|
||||
var range = part.Split('-');
|
||||
return int.TryParse(range[0], out var a) && int.TryParse(range[1], out var b) && value >= a && value <= b;
|
||||
}
|
||||
|
||||
return int.TryParse(part, out var exact) && exact == value;
|
||||
});
|
||||
}
|
||||
|
||||
private static bool HasDay(DayOfWeekFlags flags, DayOfWeek day)
|
||||
{
|
||||
var flag = day switch
|
||||
{
|
||||
DayOfWeek.Sunday => DayOfWeekFlags.Sunday,
|
||||
DayOfWeek.Monday => DayOfWeekFlags.Monday,
|
||||
DayOfWeek.Tuesday => DayOfWeekFlags.Tuesday,
|
||||
DayOfWeek.Wednesday => DayOfWeekFlags.Wednesday,
|
||||
DayOfWeek.Thursday => DayOfWeekFlags.Thursday,
|
||||
DayOfWeek.Friday => DayOfWeekFlags.Friday,
|
||||
DayOfWeek.Saturday => DayOfWeekFlags.Saturday,
|
||||
_ => DayOfWeekFlags.None
|
||||
};
|
||||
return flags.HasFlag(flag);
|
||||
}
|
||||
|
||||
private static int LastBusinessDay(int year, int month)
|
||||
{
|
||||
var date = new DateTime(year, month, DateTime.DaysInMonth(year, month));
|
||||
while (date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
|
||||
{
|
||||
date = date.AddDays(-1);
|
||||
}
|
||||
|
||||
return date.Day;
|
||||
}
|
||||
}
|
||||
|
||||
public static class StartupRegistration
|
||||
{
|
||||
private const string RunKey = @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
public static void Apply(bool enabled)
|
||||
{
|
||||
using var key = Registry.CurrentUser.OpenSubKey(RunKey, true);
|
||||
if (key is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
key.SetValue("OmniScheduler", $"\"{Environment.ProcessPath}\" --tray");
|
||||
}
|
||||
else
|
||||
{
|
||||
key.DeleteValue("OmniScheduler", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
OmniScheduler/SettingsWindow.xaml
Normal file
43
OmniScheduler/SettingsWindow.xaml
Normal file
@@ -0,0 +1,43 @@
|
||||
<Window x:Class="OmniScheduler.SettingsWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="全局设置"
|
||||
Height="330"
|
||||
Width="560"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ResizeMode="NoResize"
|
||||
Icon="app.ico">
|
||||
<DockPanel Margin="16">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,16,0,0">
|
||||
<TextBlock x:Name="ValidationText" Foreground="#B42318" VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<Button Content="保存" Width="90" Click="Save_Click" />
|
||||
<Button Content="取消" Width="90" Click="Cancel_Click" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="开机自启动" VerticalAlignment="Center" />
|
||||
<CheckBox x:Name="StartWithWindowsBox" Grid.Column="1" VerticalAlignment="Center" />
|
||||
|
||||
<TextBlock Text="OmniNotify API" Grid.Row="1" Margin="0,12,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="UrlBox" Grid.Row="1" Grid.Column="1" Margin="0,12,0,0" Height="30" />
|
||||
|
||||
<TextBlock Text="日志保留天数" Grid.Row="2" Margin="0,12,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="RetentionDaysBox" Grid.Row="2" Grid.Column="1" Margin="0,12,0,0" Height="30" />
|
||||
|
||||
<TextBlock Text="最大日志条数" Grid.Row="3" Margin="0,12,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="MaxRecordsBox" Grid.Row="3" Grid.Column="1" Margin="0,12,0,0" Height="30" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
56
OmniScheduler/SettingsWindow.xaml.cs
Normal file
56
OmniScheduler/SettingsWindow.xaml.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace OmniScheduler;
|
||||
|
||||
public partial class SettingsWindow : Window
|
||||
{
|
||||
private readonly AppSettings _settings;
|
||||
|
||||
private SettingsWindow(Window owner, AppSettings settings)
|
||||
{
|
||||
InitializeComponent();
|
||||
Owner = owner;
|
||||
_settings = settings;
|
||||
StartWithWindowsBox.IsChecked = settings.StartWithWindows;
|
||||
UrlBox.Text = settings.OmniNotifyUrl;
|
||||
RetentionDaysBox.Text = settings.LogRetentionDays.ToString();
|
||||
MaxRecordsBox.Text = settings.MaxLogRecords.ToString();
|
||||
}
|
||||
|
||||
public static bool Edit(Window owner, AppSettings settings)
|
||||
{
|
||||
var dialog = new SettingsWindow(owner, settings);
|
||||
return dialog.ShowDialog() == true;
|
||||
}
|
||||
|
||||
private void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ValidationText.Text = "";
|
||||
if (!Uri.TryCreate(UrlBox.Text.Trim(), UriKind.Absolute, out _))
|
||||
{
|
||||
ValidationText.Text = "API 地址格式不正确";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(RetentionDaysBox.Text, out var days) || days < 1)
|
||||
{
|
||||
ValidationText.Text = "日志保留天数至少为 1";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(MaxRecordsBox.Text, out var max) || max < 100)
|
||||
{
|
||||
ValidationText.Text = "最大日志条数至少为 100";
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.StartWithWindows = StartWithWindowsBox.IsChecked == true;
|
||||
_settings.OmniNotifyUrl = UrlBox.Text.Trim();
|
||||
_settings.LogRetentionDays = days;
|
||||
_settings.MaxLogRecords = max;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e)
|
||||
=> DialogResult = false;
|
||||
}
|
||||
159
OmniScheduler/TaskEditorWindow.xaml
Normal file
159
OmniScheduler/TaskEditorWindow.xaml
Normal file
@@ -0,0 +1,159 @@
|
||||
<Window x:Class="OmniScheduler.TaskEditorWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="任务编辑"
|
||||
Height="650"
|
||||
Width="860"
|
||||
MinHeight="600"
|
||||
MinWidth="780"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="app.ico">
|
||||
<DockPanel Margin="12">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<TextBlock x:Name="ValidationText" Foreground="#B42318" VerticalAlignment="Center" Margin="0,0,14,0" />
|
||||
<Button Content="保存" Width="90" Click="Save_Click" />
|
||||
<Button Content="取消" Width="90" Click="Cancel_Click" />
|
||||
</StackPanel>
|
||||
|
||||
<TabControl>
|
||||
<TabItem Header="常规">
|
||||
<Grid Margin="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="130" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="任务名称" Grid.Row="0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="NameBox" Grid.Row="0" Grid.Column="1" Height="30" />
|
||||
<TextBlock Text="启用任务" Grid.Row="1" Margin="0,12,0,0" VerticalAlignment="Center" />
|
||||
<CheckBox x:Name="EnabledBox" Grid.Row="1" Grid.Column="1" Margin="0,12,0,0" VerticalAlignment="Center" />
|
||||
<TextBlock Text="任务描述" Grid.Row="2" Margin="0,12,0,0" />
|
||||
<TextBox x:Name="DescriptionBox" Grid.Row="2" Grid.Column="1" Margin="0,12,0,0" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" />
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="触发器">
|
||||
<Grid Margin="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="245" />
|
||||
<ColumnDefinition Width="14" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<DockPanel Grid.Column="0">
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,8,0,0">
|
||||
<Button Content="添加" Click="AddTrigger_Click" />
|
||||
<Button Content="删除" Click="RemoveTrigger_Click" />
|
||||
</StackPanel>
|
||||
<ListBox x:Name="TriggersList" DisplayMemberPath="Summary" SelectionChanged="TriggersList_SelectionChanged" />
|
||||
</DockPanel>
|
||||
|
||||
<Border Grid.Column="2" BorderBrush="#D8DEE9" BorderThickness="1" Padding="12">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="150" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="触发类型" VerticalAlignment="Center" />
|
||||
<ComboBox x:Name="KindBox" Grid.Column="1" Height="30" SelectionChanged="Field_Changed" />
|
||||
|
||||
<TextBlock Text="单次时间" Grid.Row="1" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="OneTimeBox" Grid.Row="1" Grid.Column="1" Margin="0,10,0,0" Height="30" ToolTip="格式:yyyy-MM-dd HH:mm:ss" TextChanged="Field_Changed" />
|
||||
|
||||
<TextBlock Text="间隔" Grid.Row="2" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="2" Grid.Column="1" Margin="0,10,0,0" Orientation="Horizontal">
|
||||
<TextBox x:Name="IntervalValueBox" Width="80" Height="30" TextChanged="Field_Changed" />
|
||||
<ComboBox x:Name="IntervalUnitBox" Width="120" Height="30" Margin="8,0,0,0" SelectionChanged="Field_Changed" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="起止时间" Grid.Row="3" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="3" Grid.Column="1" Margin="0,10,0,0" Orientation="Horizontal">
|
||||
<TextBox x:Name="StartsAtBox" Width="180" Height="30" ToolTip="留空表示立即生效" TextChanged="Field_Changed" />
|
||||
<TextBox x:Name="EndsAtBox" Width="180" Height="30" Margin="8,0,0,0" ToolTip="留空表示长期有效" TextChanged="Field_Changed" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="每日/每周时间" Grid.Row="4" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="TimeOfDayBox" Grid.Row="4" Grid.Column="1" Margin="0,10,0,0" Height="30" ToolTip="格式:HH:mm:ss" TextChanged="Field_Changed" />
|
||||
|
||||
<TextBlock Text="周几" Grid.Row="5" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<WrapPanel Grid.Row="5" Grid.Column="1" Margin="0,10,0,0">
|
||||
<CheckBox x:Name="SunBox" Content="日" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
<CheckBox x:Name="MonBox" Content="一" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
<CheckBox x:Name="TueBox" Content="二" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
<CheckBox x:Name="WedBox" Content="三" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
<CheckBox x:Name="ThuBox" Content="四" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
<CheckBox x:Name="FriBox" Content="五" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
<CheckBox x:Name="SatBox" Content="六" Margin="0,0,10,0" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Text="每月" Grid.Row="6" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Row="6" Grid.Column="1" Margin="0,10,0,0" Orientation="Horizontal">
|
||||
<TextBox x:Name="MonthDayBox" Width="80" Height="30" TextChanged="Field_Changed" />
|
||||
<CheckBox x:Name="LastBusinessDayBox" Content="最后一个工作日" Margin="10,0,0,0" VerticalAlignment="Center" Checked="Field_Changed" Unchecked="Field_Changed" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Cron 表达式" Grid.Row="7" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="CronBox" Grid.Row="7" Grid.Column="1" Margin="0,10,0,0" Height="30" TextChanged="Field_Changed" />
|
||||
|
||||
<GroupBox Grid.Row="8" Grid.ColumnSpan="2" Header="高级调度与预览" Margin="0,14,0,0">
|
||||
<DockPanel Margin="10">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
|
||||
<TextBlock Text="错过触发补偿策略" VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||
<ComboBox x:Name="MisfireBox" Width="180" Height="30" />
|
||||
</StackPanel>
|
||||
<TextBox x:Name="PreviewBox" Margin="0,12,0,0" IsReadOnly="True" AcceptsReturn="True" Height="130" />
|
||||
</DockPanel>
|
||||
</GroupBox>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="消息动作">
|
||||
<Grid Margin="14">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="130" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Text="目标地址" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="TargetUrlBox" Grid.Column="1" Height="30" IsReadOnly="True" />
|
||||
<TextBlock Text="Channel" Grid.Row="1" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<ComboBox x:Name="ChannelBox" Grid.Row="1" Grid.Column="1" Margin="0,10,0,0" Height="30" IsEditable="True" />
|
||||
<TextBlock Text="Title" Grid.Row="2" Margin="0,10,0,0" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="TitleBox" Grid.Row="2" Grid.Column="1" Margin="0,10,0,0" Height="30" />
|
||||
<TextBlock Text="Body" Grid.Row="3" Margin="0,10,0,0" />
|
||||
<TextBox x:Name="BodyBox" Grid.Row="3" Grid.Column="1" Margin="0,10,0,0" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" />
|
||||
<StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" Margin="0,10,0,0">
|
||||
<Button Content="Send Test Message" Click="TestSend_Click" />
|
||||
<TextBlock x:Name="TestResultText" VerticalAlignment="Center" Margin="8,0,0,0" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
246
OmniScheduler/TaskEditorWindow.xaml.cs
Normal file
246
OmniScheduler/TaskEditorWindow.xaml.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
|
||||
namespace OmniScheduler;
|
||||
|
||||
public partial class TaskEditorWindow : Window
|
||||
{
|
||||
private readonly ScheduledTask _task;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly NotifyClient _client;
|
||||
private bool _loading;
|
||||
|
||||
private TaskEditorWindow(Window owner, ScheduledTask task, AppSettings settings, NotifyClient client)
|
||||
{
|
||||
InitializeComponent();
|
||||
Owner = owner;
|
||||
_task = task;
|
||||
_settings = settings;
|
||||
_client = client;
|
||||
|
||||
KindBox.ItemsSource = Enum.GetValues<TriggerKind>();
|
||||
IntervalUnitBox.ItemsSource = Enum.GetValues<IntervalUnit>();
|
||||
MisfireBox.ItemsSource = Enum.GetValues<MisfireStrategy>();
|
||||
ChannelBox.ItemsSource = owner is MainWindow main
|
||||
? main.State.Tasks.Select(t => t.Channel).Where(c => !string.IsNullOrWhiteSpace(c)).Distinct().ToList()
|
||||
: null;
|
||||
|
||||
LoadTask();
|
||||
}
|
||||
|
||||
public static bool Edit(Window owner, ScheduledTask task, AppSettings settings, NotifyClient client)
|
||||
{
|
||||
var workingCopy = task.Clone();
|
||||
workingCopy.Id = task.Id;
|
||||
workingCopy.Name = task.Name;
|
||||
var dialog = new TaskEditorWindow(owner, workingCopy, settings, client);
|
||||
if (dialog.ShowDialog() == true)
|
||||
{
|
||||
task.CopyFrom(workingCopy);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void LoadTask()
|
||||
{
|
||||
NameBox.Text = _task.Name;
|
||||
DescriptionBox.Text = _task.Description;
|
||||
EnabledBox.IsChecked = _task.IsEnabled;
|
||||
TargetUrlBox.Text = _settings.OmniNotifyUrl;
|
||||
ChannelBox.Text = _task.Channel;
|
||||
TitleBox.Text = _task.Title;
|
||||
BodyBox.Text = _task.Body;
|
||||
MisfireBox.SelectedItem = _task.MisfireStrategy;
|
||||
TriggersList.ItemsSource = _task.Triggers;
|
||||
TriggersList.SelectedIndex = _task.Triggers.Count > 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
private void LoadTrigger(TaskTrigger? trigger)
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
if (trigger is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
KindBox.SelectedItem = trigger.Kind;
|
||||
OneTimeBox.Text = trigger.OneTimeAt.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
IntervalValueBox.Text = trigger.IntervalValue.ToString();
|
||||
IntervalUnitBox.SelectedItem = trigger.IntervalUnit;
|
||||
StartsAtBox.Text = trigger.StartsAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "";
|
||||
EndsAtBox.Text = trigger.EndsAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "";
|
||||
TimeOfDayBox.Text = trigger.TimeOfDay.ToString(@"hh\:mm\:ss");
|
||||
MonthDayBox.Text = trigger.MonthDay.ToString();
|
||||
LastBusinessDayBox.IsChecked = trigger.LastBusinessDay;
|
||||
CronBox.Text = trigger.CronExpression;
|
||||
|
||||
SunBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Sunday);
|
||||
MonBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Monday);
|
||||
TueBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Tuesday);
|
||||
WedBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Wednesday);
|
||||
ThuBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Thursday);
|
||||
FriBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Friday);
|
||||
SatBox.IsChecked = trigger.WeekDays.HasFlag(DayOfWeekFlags.Saturday);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
RefreshPreview();
|
||||
}
|
||||
}
|
||||
|
||||
private bool SaveTriggerFields()
|
||||
{
|
||||
if (_loading || TriggersList.SelectedItem is not TaskTrigger trigger)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
trigger.Kind = KindBox.SelectedItem is TriggerKind kind ? kind : TriggerKind.Interval;
|
||||
trigger.IntervalUnit = IntervalUnitBox.SelectedItem is IntervalUnit unit ? unit : IntervalUnit.Minutes;
|
||||
trigger.LastBusinessDay = LastBusinessDayBox.IsChecked == true;
|
||||
trigger.CronExpression = CronBox.Text.Trim();
|
||||
|
||||
if (int.TryParse(IntervalValueBox.Text, out var interval))
|
||||
{
|
||||
trigger.IntervalValue = interval;
|
||||
}
|
||||
|
||||
if (int.TryParse(MonthDayBox.Text, out var monthDay))
|
||||
{
|
||||
trigger.MonthDay = Math.Clamp(monthDay, 1, 31);
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(OneTimeBox.Text, out var oneTime))
|
||||
{
|
||||
trigger.OneTimeAt = oneTime;
|
||||
}
|
||||
|
||||
trigger.StartsAt = DateTime.TryParse(StartsAtBox.Text, out var startsAt) ? startsAt : null;
|
||||
trigger.EndsAt = DateTime.TryParse(EndsAtBox.Text, out var endsAt) ? endsAt : null;
|
||||
|
||||
if (TimeSpan.TryParse(TimeOfDayBox.Text, out var time))
|
||||
{
|
||||
trigger.TimeOfDay = time;
|
||||
}
|
||||
|
||||
var days = DayOfWeekFlags.None;
|
||||
if (SunBox.IsChecked == true) days |= DayOfWeekFlags.Sunday;
|
||||
if (MonBox.IsChecked == true) days |= DayOfWeekFlags.Monday;
|
||||
if (TueBox.IsChecked == true) days |= DayOfWeekFlags.Tuesday;
|
||||
if (WedBox.IsChecked == true) days |= DayOfWeekFlags.Wednesday;
|
||||
if (ThuBox.IsChecked == true) days |= DayOfWeekFlags.Thursday;
|
||||
if (FriBox.IsChecked == true) days |= DayOfWeekFlags.Friday;
|
||||
if (SatBox.IsChecked == true) days |= DayOfWeekFlags.Saturday;
|
||||
trigger.WeekDays = days;
|
||||
trigger.Notify(nameof(TaskTrigger.Summary));
|
||||
TriggersList.Items.Refresh();
|
||||
RefreshPreview();
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool SaveTaskFields()
|
||||
{
|
||||
ValidationText.Text = "";
|
||||
SaveTriggerFields();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(NameBox.Text))
|
||||
{
|
||||
ValidationText.Text = "任务名称不能为空";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var trigger in _task.Triggers)
|
||||
{
|
||||
if (trigger.Kind == TriggerKind.Interval && trigger.IntervalValue < 1)
|
||||
{
|
||||
ValidationText.Text = "固定间隔不能小于 1";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
_task.Name = NameBox.Text.Trim();
|
||||
_task.Description = DescriptionBox.Text.Trim();
|
||||
_task.IsEnabled = EnabledBox.IsChecked == true;
|
||||
_task.Channel = ChannelBox.Text.Trim();
|
||||
_task.Title = TitleBox.Text.Trim();
|
||||
_task.Body = BodyBox.Text;
|
||||
_task.MisfireStrategy = MisfireBox.SelectedItem is MisfireStrategy strategy ? strategy : MisfireStrategy.DoNothing;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void TriggersList_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
=> LoadTrigger(TriggersList.SelectedItem as TaskTrigger);
|
||||
|
||||
private void AddTrigger_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var trigger = new TaskTrigger();
|
||||
_task.Triggers.Add(trigger);
|
||||
TriggersList.SelectedItem = trigger;
|
||||
TriggersList.Items.Refresh();
|
||||
}
|
||||
|
||||
private void RemoveTrigger_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (TriggersList.SelectedItem is TaskTrigger trigger)
|
||||
{
|
||||
_task.Triggers.Remove(trigger);
|
||||
TriggersList.Items.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void Field_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_loading)
|
||||
{
|
||||
SaveTriggerFields();
|
||||
}
|
||||
}
|
||||
|
||||
private async void TestSend_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!SaveTaskFields())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TestResultText.Text = "发送中...";
|
||||
var log = await _client.SendAsync(_task, "Test", _settings.OmniNotifyUrl);
|
||||
TestResultText.Text = log.Level == "INFO" ? "测试发送成功" : log.Message;
|
||||
TestResultText.Foreground = log.Level == "INFO"
|
||||
? System.Windows.Media.Brushes.ForestGreen
|
||||
: System.Windows.Media.Brushes.Firebrick;
|
||||
}
|
||||
|
||||
private void Save_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!SaveTaskFields())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void Cancel_Click(object sender, RoutedEventArgs e)
|
||||
=> DialogResult = false;
|
||||
|
||||
private void RefreshPreview()
|
||||
{
|
||||
if (TriggersList.SelectedItem is not TaskTrigger trigger)
|
||||
{
|
||||
PreviewBox.Text = "";
|
||||
return;
|
||||
}
|
||||
|
||||
var preview = NextRunCalculator.Preview(trigger, DateTime.Now);
|
||||
PreviewBox.Text = preview.Count == 0
|
||||
? "无法计算未来执行时间,请检查规则。"
|
||||
: string.Join(Environment.NewLine, preview.Select(d => d.ToString("yyyy-MM-dd HH:mm:ss")));
|
||||
}
|
||||
}
|
||||
123
PRD.md
Normal file
123
PRD.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# OmniScheduler 产品需求与交互说明书 (PRD)
|
||||
|
||||
## 1. 产品概述
|
||||
**OmniScheduler** 是一款专为 Windows 系统设计的本地高级任务调度与消息推送管理软件。其核心目标是允许用户创建高度灵活的定时/计时规则,并在规则触发时,向本地环境的 `OmniNotify` 软件标准 API 发送结构化的 JSON 消息(包含频道、标题、内容)。
|
||||
产品风格定位为**技术工具型软件**,追求逻辑严密性、高信息密度、无干扰的后台运行体验以及所见即所得的日志反馈。
|
||||
|
||||
## 2. 核心术语
|
||||
* **任务 (Task)**:一个完整的调度单元,包含基础信息、触发器和消息载荷。
|
||||
* **触发器 (Trigger)**:定义任务何时运行的时间规则(如固定时间、间隔、Cron表达式等)。
|
||||
* **消息载荷 (Payload)**:触发时向 OmniNotify 发送的具体内容,由 `channel`(频道)、`title`(标题)、`body`(内容)组成。
|
||||
* **补偿策略 (Misfire Strategy)**:当系统休眠或关机导致错过了原定触发时间后,系统恢复时如何处理这些错过的触发。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心功能需求
|
||||
|
||||
### 3.1 任务管理 (Task Management)
|
||||
* **任务属性**:
|
||||
* **任务名称**:必填,用于在列表中识别任务。
|
||||
* **任务描述**:选填,用于记录任务用途的备忘。
|
||||
* **状态开关**:启用 (Enabled) / 禁用 (Disabled)。禁用状态下的任务不会触发。
|
||||
* **列表操作**:支持新建、编辑、删除、克隆(复制现有任务以便微调)、立即手动执行一次(用于测试)。
|
||||
|
||||
### 3.2 触发器配置引擎 (Trigger Engine)
|
||||
为满足“丰富的可配置性”,一个任务允许配置**单个或多个**触发器。支持以下触发器类型:
|
||||
1. **单次执行 (One-Time)**:指定未来的某一个具体日期和时间点。
|
||||
2. **固定间隔 (Simple Interval)**:
|
||||
* 配置项:每隔 `X` [秒/分钟/小时/天] 执行一次。
|
||||
* 附加项:可设置“起始生效时间”和“结束失效时间”。
|
||||
3. **每日/每周/每月定时 (Calendar Scheduled)**:
|
||||
* 每日:每天 `HH:mm:ss` 执行(可添加多个时间点)。
|
||||
* 每周:勾选周一至周日,在选定日的 `HH:mm:ss` 执行。
|
||||
* 每月:每月的第 `X` 天 或 每月的“最后一个工作日”的 `HH:mm:ss` 执行。
|
||||
4. **高级规则 (Cron Expression)** *(技术向特色功能)*:
|
||||
* 允许用户直接输入标准的 Cron 表达式(如 `0 0/5 14 * * ?`)。
|
||||
* UI 需提供 Cron 表达式的“未来5次执行时间”的实时预览,方便用户自测。
|
||||
|
||||
**高级调度设置(补偿策略)**:
|
||||
* 当系统从休眠中唤醒,发现错过了某次执行时,用户可配置:
|
||||
* *忽略 (Do Nothing)*:当作没发生,等待下一个周期。
|
||||
* *立即补偿 (Fire Once Now)*:立即补发一次消息,随后恢复正常计划。
|
||||
|
||||
### 3.3 消息载荷配置 (Payload Configuration)
|
||||
任务触发后生成的 JSON 数据配置区:
|
||||
* **频道 (Channel)**:单行文本输入框。提示用户“必须与 OmniNotify 控制面板中已创建的频道一致,否则将被 OmniNotify 拦截”。
|
||||
* **标题 (Title)**:单行文本输入框。
|
||||
* **内容 (Body)**:多行文本输入框,支持自动换行。
|
||||
* **动态变量支持 (Dynamic Variables)** *(技术向特色功能)*:
|
||||
* 允许在 Title 和 Body 中使用预设变量,以丰富消息内容。
|
||||
* 例如:`{CurrentTime}` (当前时间), `{TaskName}` (任务名称), `{TriggerType}` (触发类型)。在发送时自动替换为真实值。
|
||||
|
||||
### 3.4 运行日志与审计 (Execution Logs)
|
||||
* **本地日志记录**:记录每一次任务触发的详细情况。
|
||||
* **日志字段**:时间戳、任务名称、触发类型、目标 URL、HTTP 响应状态码(成功为 200,失败/拒绝需标红)、响应耗时。
|
||||
* **详情查阅**:点击单条日志可查看实际发出的完整 JSON 请求体及接收方的响应。
|
||||
|
||||
---
|
||||
|
||||
## 4. 界面与交互规范 (UI/UX)
|
||||
|
||||
整体 UI 风格向 Visual Studio、Postman 或 Windows 本地管理工具(MMC)靠拢。采用高对比度、紧凑的数据表格布局,支持 Windows 系统的深色/浅色模式切换。
|
||||
|
||||
### 4.1 主窗口布局 (Main Dashboard)
|
||||
主界面采用 **“上下分栏 (Split View)”** 布局:
|
||||
|
||||
* **顶部工具栏 (Toolbar)**:
|
||||
* 包含图标按钮:`新建任务`、`编辑`、`删除`、`克隆`、`手动触发`、`全局设置`。
|
||||
* **上半部分:任务列表区 (DataGrid)**:
|
||||
* 以数据表格形式展示。
|
||||
* 列包含:状态 (Toggle Switch)、任务名称、频道、触发规则简述、上次运行时间、下次运行时间、最近一次状态 (成功/失败指示灯)。
|
||||
* 支持按列头点击排序。
|
||||
* **下半部分:执行日志区 (Log Console)**:
|
||||
* 类似控制台输出,按时间倒序滚动显示近期日志。
|
||||
* 分为 `INFO` (正常发送) 和 `ERROR` (发送失败或 OmniNotify 未启动/拒收)。
|
||||
* 提供“清空日志”按钮。
|
||||
|
||||
### 4.2 任务编辑窗口 (Task Editor Modal)
|
||||
采用 **“左侧导航树 + 右侧属性面板”** 或 **“多标签页 (Tabs)”** 形式:
|
||||
|
||||
* **Tab 1: 常规 (General)**
|
||||
* 输入任务名称、描述,以及启用/禁用状态开关。
|
||||
* **Tab 2: 触发器 (Triggers)**
|
||||
* 左侧列出该任务已添加的触发器,右侧为具体配置表单。
|
||||
* 下拉菜单选择触发器类型(间隔、每日、Cron等),下方表单根据类型动态切换。
|
||||
* 最底部折叠面板提供“错过触发补偿策略”设置。
|
||||
* **Tab 3: 消息动作 (Action/Payload)**
|
||||
* **目标地址**:默认只读显示 `http://127.0.0.1:19845/notify`(随全局设置变动)。
|
||||
* **载荷表单**:
|
||||
* Channel (带输入历史记忆的下拉文本框)
|
||||
* Title
|
||||
* Body
|
||||
* **测试按钮**:提供一个 `Send Test Message` 按钮,点击后立即使用当前填写的 Channel/Title/Body 向 OmniNotify 发送一次请求,并在旁边气泡提示成功或失败,无需保存任务即可验证频道是否合法。
|
||||
|
||||
### 4.3 托盘与后台交互 (System Tray)
|
||||
* **最小化到托盘**:由于是定时软件,点击主窗口的“关闭”按钮默认不退出程序,而是隐藏到系统托盘 (System Tray)。
|
||||
* **托盘右键菜单**:
|
||||
* 打开主界面 (Open Dashboard)
|
||||
* 快速暂停所有任务 (Pause All Tasks)
|
||||
* 退出 (Exit)
|
||||
* **静默运行**:软件启动和运行时不应有任何强制弹窗打扰用户,所有通知全权交由 OmniNotify 处理。
|
||||
|
||||
---
|
||||
|
||||
## 5. 全局设置 (Global Settings)
|
||||
|
||||
在独立的“设置”弹窗中提供以下全局配置:
|
||||
1. **开机自启动**:跟随 Windows 登录启动(最小化到托盘)。
|
||||
2. **OmniNotify API 地址**:默认值为 `http://127.0.0.1:19845/notify`。提供修改入口,以防未来 OmniNotify 端口或地址发生变更。
|
||||
3. **日志保留策略**:防止本地日志无限膨胀,例如“保留最近 30 天的日志”或“最多保留 10000 条记录”。
|
||||
|
||||
## 6. 异常流与边界说明 (Exception Flows)
|
||||
|
||||
1. **OmniNotify 未运行或端口被占用**:
|
||||
* 触发器正常到点执行,但网络请求失败。
|
||||
* 系统不应弹窗报错(避免积压成百上千个弹窗导致死机),只需在“执行日志区”静默记录一条标红的 `ERROR` 日志(如 `Connection Refused`)。
|
||||
2. **OmniNotify 频道校验失败 (`IllegalChannel`)**:
|
||||
* 触发器正常执行,网络请求发出。
|
||||
* 收到 HTTP 状态异常(或特定拒收报文)。
|
||||
* 日志区记录 `ERROR: IllegalChannel`,提示用户检查 OmniScheduler 中的频道名配置。
|
||||
3. **并发触发 (Concurrent Execution)**:
|
||||
* 若用户配置了大量的任务在同一秒(如 `00:00:00`)触发,系统需具备并行发出网络请求的能力,不能因为单条消息的发送阻塞导致后续消息延迟。
|
||||
4. **规则冲突检查**:
|
||||
* UI 层面在用户保存时,如果发现“固定间隔”被设置为 0 秒,需高亮报错并阻断保存。最低间隔建议限制为 1 秒。
|
||||
Reference in New Issue
Block a user