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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
bin/
obj/
**/bin/
**/obj/
.vs/
*.user
*.suo

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

123
PRD.md Normal file
View 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 秒。

BIN
app.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB