feat(app): 初始化 OmniScheduler WPF 调度器

基于 PRD 搭建 .NET 8 WPF 桌面应用,包含主控制台、任务编辑、全局设置、系统托盘和应用图标集成。

实现本地任务模型、触发器规则、JSON 状态持久化、OmniNotify HTTP 推送、执行日志记录、动态变量替换以及基础 Cron 预览能力。

补充 .gitignore,排除构建产物和本地 IDE 文件。

BREAKING CHANGE: 首次提交,建立项目初始结构
This commit is contained in:
2026-05-20 00:12:17 +08:00
commit 2a669bdfe7
15 changed files with 1733 additions and 0 deletions

9
OmniScheduler/App.xaml Normal file
View 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>

View File

@@ -0,0 +1,5 @@
namespace OmniScheduler;
public partial class App : System.Windows.Application
{
}

View 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)
)]

View 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>

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

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

View 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>

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

View 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>

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