feat(app): 初始化本地通知桌面应用

搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。
实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。
补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。

Initial-Commit: true
This commit is contained in:
2026-05-19 01:32:41 +08:00
commit c353845fad
15 changed files with 1909 additions and 0 deletions

372
MainWindow.xaml.cs Normal file
View File

@@ -0,0 +1,372 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using Clipboard = System.Windows.Clipboard;
using MessageBox = System.Windows.MessageBox;
namespace OmniNotify;
public partial class MainWindow : Window
{
private const string AllChannelsLabel = "全部频道";
private const string AllStatusesLabel = "全部状态";
private readonly App _app;
private readonly AppState _state;
private readonly ICollectionView _historyView;
private bool _loading;
public MainWindow()
{
InitializeComponent();
_app = (App)System.Windows.Application.Current;
_state = _app.State;
AnchorBox.ItemsSource = LabeledValues(
(ScreenAnchor.TopLeft, "左上"),
(ScreenAnchor.TopCenter, "顶部居中"),
(ScreenAnchor.TopRight, "右上"),
(ScreenAnchor.MiddleLeft, "左侧居中"),
(ScreenAnchor.Center, "屏幕中央"),
(ScreenAnchor.MiddleRight, "右侧居中"),
(ScreenAnchor.BottomLeft, "左下"),
(ScreenAnchor.BottomCenter, "底部居中"),
(ScreenAnchor.BottomRight, "右下"));
EnterAnimationBox.ItemsSource = LabeledValues(
(PopupAnimation.Fade, "淡入淡出"),
(PopupAnimation.Slide, "滑入滑出"),
(PopupAnimation.Zoom, "缩放"));
ExitAnimationBox.ItemsSource = EnterAnimationBox.ItemsSource;
StackModeBox.ItemsSource = LabeledValues(
(StackMode.Queue, "排队显示"),
(StackMode.Push, "并排堆叠"),
(StackMode.Replace, "替换旧弹窗"));
OverflowModeBox.ItemsSource = LabeledValues(
(OverflowMode.Truncate, "截断省略"),
(OverflowMode.VerticalScroll, "纵向滚动"),
(OverflowMode.Split, "分割显示"));
ChannelList.ItemsSource = _state.Channels;
HistoryGrid.ItemsSource = _state.History;
_historyView = CollectionViewSource.GetDefaultView(_state.History);
_historyView.Filter = FilterHistory;
_app.Router.StateChanged += RefreshAll;
Loaded += (_, _) => RefreshAll();
Closing += MainWindow_Closing;
}
public void FocusSettingsTab()
{
MainTabs.SelectedIndex = 2;
}
private void RefreshAll()
{
Dispatcher.Invoke(() =>
{
_loading = true;
ServerStatusText.Text = string.IsNullOrWhiteSpace(_app.ServerError)
? $"本地接收地址:{_app.ListenUrl}notify"
: $"本地监听失败:{_app.ServerError}";
RefreshHistoryFilters();
LoadSettings();
LoadSelectedChannel();
ResetBreakerButton.IsEnabled = _state.Settings.CircuitBreakerOpen;
_historyView.Refresh();
_loading = false;
});
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
{
e.Cancel = true;
Hide();
}
private void AddChannel_Click(object sender, RoutedEventArgs e)
{
SaveSelectedChannel();
var baseName = "channel";
var index = 1;
while (_state.Channels.Any(channel => channel.Name == $"{baseName}-{index}"))
{
index++;
}
var item = new Channel { Name = $"{baseName}-{index}" };
_state.Channels.Add(item);
ChannelList.SelectedItem = item;
_app.SaveState();
RefreshHistoryFilters();
}
private void DeleteChannel_Click(object sender, RoutedEventArgs e)
{
if (ChannelList.SelectedItem is not Channel channel)
{
return;
}
if (MessageBox.Show($"确认删除频道 {channel.Name}", "删除频道", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
{
return;
}
_state.Channels.Remove(channel);
ChannelList.SelectedIndex = _state.Channels.Count > 0 ? 0 : -1;
_app.SaveState();
RefreshHistoryFilters();
}
private void ChannelList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_loading)
{
return;
}
LoadSelectedChannel();
}
private void Save_Click(object sender, RoutedEventArgs e)
{
SaveSelectedChannel();
SaveSettings();
_app.SaveState();
MessageBox.Show("配置已保存。", "Omni-Notify", MessageBoxButton.OK, MessageBoxImage.Information);
}
private void SendTest_Click(object sender, RoutedEventArgs e)
{
SaveSelectedChannel();
var channel = ChannelList.SelectedItem as Channel ?? _state.Channels.FirstOrDefault();
if (channel is null)
{
MessageBox.Show("请先创建频道。", "Omni-Notify", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
_app.Router.Receive(new IncomingMessage
{
Channel = channel.Name,
Title = "Omni-Notify 测试消息",
Body = "这是一条按当前频道样式展示的纯文本弹窗,用于验证位置、尺寸、透明度、动画、历史记录,以及在内容较长时的截断、纵向滚动或分割显示效果。"
});
}
private void LoadSelectedChannel()
{
_loading = true;
if (ChannelList.SelectedItem is null && _state.Channels.Count > 0)
{
ChannelList.SelectedIndex = 0;
}
if (ChannelList.SelectedItem is Channel channel)
{
var profile = channel.Profile;
ChannelNameBox.Text = channel.Name;
ScreenIndexBox.Text = profile.ScreenIndex.ToString();
AnchorBox.SelectedValue = profile.Anchor;
MarginXBox.Text = profile.MarginX.ToString("0.##");
MarginYBox.Text = profile.MarginY.ToString("0.##");
WidthBox.Text = profile.Width.ToString("0.##");
MaxHeightBox.Text = profile.MaxHeight.ToString("0.##");
PaddingBox.Text = profile.Padding.ToString("0.##");
FontFamilyBox.Text = profile.FontFamily;
TitleFontSizeBox.Text = profile.TitleFontSize.ToString("0.##");
BodyFontSizeBox.Text = profile.BodyFontSize.ToString("0.##");
TextColorBox.Text = profile.TextColor;
BackgroundColorBox.Text = profile.BackgroundColor;
BackgroundOpacityBox.Text = profile.BackgroundOpacity.ToString("0.##");
BorderColorBox.Text = profile.BorderColor;
BorderOpacityBox.Text = profile.BorderOpacity.ToString("0.##");
OverallOpacityBox.Text = profile.OverallOpacity.ToString("0.##");
LifetimeBox.Text = profile.LifetimeSeconds.ToString("0.##");
EnterAnimationBox.SelectedValue = profile.EnterAnimation;
ExitAnimationBox.SelectedValue = profile.ExitAnimation;
StackModeBox.SelectedValue = profile.StackMode;
OverflowModeBox.SelectedValue = profile.OverflowMode;
SplitIntervalBox.Text = profile.SplitIntervalSeconds.ToString("0.##");
VerticalScrollHoldBox.Text = profile.VerticalScrollHoldSeconds.ToString("0.##");
VerticalScrollSpeedBox.Text = profile.VerticalScrollSpeed.ToString("0.##");
}
_loading = false;
}
private void SaveSelectedChannel()
{
if (ChannelList.SelectedItem is not Channel channel)
{
return;
}
var newName = ChannelNameBox.Text.Trim();
if (string.IsNullOrWhiteSpace(newName))
{
newName = channel.Name;
}
if (_state.Channels.Any(candidate => candidate != channel && candidate.Name == newName))
{
MessageBox.Show("频道名必须唯一。", "Omni-Notify", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
channel.Name = newName;
var profile = channel.Profile;
profile.ScreenIndex = ReadInt(ScreenIndexBox, profile.ScreenIndex);
profile.Anchor = AnchorBox.SelectedValue as ScreenAnchor? ?? profile.Anchor;
profile.MarginX = ReadDouble(MarginXBox, profile.MarginX);
profile.MarginY = ReadDouble(MarginYBox, profile.MarginY);
profile.Width = ReadDouble(WidthBox, profile.Width);
profile.MaxHeight = ReadDouble(MaxHeightBox, profile.MaxHeight);
profile.Padding = ReadDouble(PaddingBox, profile.Padding);
profile.FontFamily = FontFamilyBox.Text.Trim();
profile.TitleFontSize = ReadDouble(TitleFontSizeBox, profile.TitleFontSize);
profile.BodyFontSize = ReadDouble(BodyFontSizeBox, profile.BodyFontSize);
profile.TextColor = TextColorBox.Text.Trim();
profile.BackgroundColor = BackgroundColorBox.Text.Trim();
profile.BackgroundOpacity = ReadDouble(BackgroundOpacityBox, profile.BackgroundOpacity);
profile.BorderColor = BorderColorBox.Text.Trim();
profile.BorderOpacity = ReadDouble(BorderOpacityBox, profile.BorderOpacity);
profile.OverallOpacity = ReadDouble(OverallOpacityBox, profile.OverallOpacity);
profile.LifetimeSeconds = ReadDouble(LifetimeBox, profile.LifetimeSeconds);
profile.EnterAnimation = EnterAnimationBox.SelectedValue as PopupAnimation? ?? profile.EnterAnimation;
profile.ExitAnimation = ExitAnimationBox.SelectedValue as PopupAnimation? ?? profile.ExitAnimation;
profile.StackMode = StackModeBox.SelectedValue as StackMode? ?? profile.StackMode;
profile.OverflowMode = OverflowModeBox.SelectedValue as OverflowMode? ?? profile.OverflowMode;
profile.SplitIntervalSeconds = ReadDouble(SplitIntervalBox, profile.SplitIntervalSeconds);
profile.VerticalScrollHoldSeconds = ReadDouble(VerticalScrollHoldBox, profile.VerticalScrollHoldSeconds);
profile.VerticalScrollSpeed = ReadDouble(VerticalScrollSpeedBox, profile.VerticalScrollSpeed);
ChannelList.Items.Refresh();
}
private void LoadSettings()
{
StartWithWindowsBox.IsChecked = _state.Settings.StartWithWindows;
DndBox.IsChecked = _state.Settings.DndEnabled;
RateLimitBox.Text = _state.Settings.MaxMessagesPerSecond.ToString();
RetainDaysBox.Text = _state.Settings.RetainDays.ToString();
RetainCountBox.Text = _state.Settings.RetainCount.ToString();
PortBox.Text = _state.Settings.LocalPort.ToString();
}
private void SaveSettings()
{
_state.Settings.StartWithWindows = StartWithWindowsBox.IsChecked == true;
_state.Settings.DndEnabled = DndBox.IsChecked == true;
_state.Settings.MaxMessagesPerSecond = ReadInt(RateLimitBox, _state.Settings.MaxMessagesPerSecond);
_state.Settings.RetainDays = ReadInt(RetainDaysBox, _state.Settings.RetainDays);
_state.Settings.RetainCount = ReadInt(RetainCountBox, _state.Settings.RetainCount);
_state.Settings.LocalPort = ReadInt(PortBox, _state.Settings.LocalPort);
StartupManager.Apply(_state.Settings.StartWithWindows);
}
private void RefreshHistoryFilters()
{
var selectedChannel = HistoryChannelFilter.SelectedItem as string ?? AllChannelsLabel;
HistoryChannelFilter.ItemsSource = new[] { AllChannelsLabel }.Concat(_state.Channels.Select(channel => channel.Name)).ToList();
HistoryChannelFilter.SelectedItem = HistoryChannelFilter.Items.Contains(selectedChannel) ? selectedChannel : AllChannelsLabel;
var selectedStatus = HistoryStatusFilter.SelectedItem as string ?? AllStatusesLabel;
HistoryStatusFilter.ItemsSource = new[] { AllStatusesLabel }.Concat(Enum.GetValues<NotificationStatus>().Select(StatusLabel)).ToList();
HistoryStatusFilter.SelectedItem = HistoryStatusFilter.Items.Contains(selectedStatus) ? selectedStatus : AllStatusesLabel;
}
private bool FilterHistory(object item)
{
if (item is not HistoryItem history)
{
return false;
}
var keyword = SearchBox.Text.Trim();
var channel = HistoryChannelFilter.SelectedItem as string;
var status = HistoryStatusFilter.SelectedItem as string;
if (!string.IsNullOrWhiteSpace(keyword) &&
!history.Title.Contains(keyword, StringComparison.OrdinalIgnoreCase) &&
!history.Body.Contains(keyword, StringComparison.OrdinalIgnoreCase) &&
!history.Channel.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(channel) && channel != AllChannelsLabel && history.Channel != channel)
{
return false;
}
return string.IsNullOrWhiteSpace(status) || status == AllStatusesLabel || StatusLabel(history.Status) == status;
}
private void HistoryFilter_Changed(object sender, RoutedEventArgs e)
{
if (!_loading)
{
_historyView.Refresh();
}
}
private void ClearHistory_Click(object sender, RoutedEventArgs e)
{
if (MessageBox.Show("确认清空所有历史记录?", "清空历史", MessageBoxButton.YesNo, MessageBoxImage.Warning) != MessageBoxResult.Yes)
{
return;
}
_state.History.Clear();
_app.SaveState();
}
private void CopyHistory_Click(object sender, RoutedEventArgs e)
{
if (HistoryGrid.SelectedItem is HistoryItem item)
{
Clipboard.SetText($"{item.Title}{Environment.NewLine}{item.Body}");
}
}
private void ReplayHistory_Click(object sender, RoutedEventArgs e)
{
if (HistoryGrid.SelectedItem is HistoryItem item)
{
_app.Router.Replay(item);
}
}
private void ResetBreaker_Click(object sender, RoutedEventArgs e)
{
_app.Router.ResetCircuitBreaker();
}
private static int ReadInt(System.Windows.Controls.TextBox box, int fallback)
{
return int.TryParse(box.Text, out var value) ? Math.Max(0, value) : fallback;
}
private static double ReadDouble(System.Windows.Controls.TextBox box, double fallback)
{
return double.TryParse(box.Text, out var value) ? Math.Max(0, value) : fallback;
}
private static IReadOnlyList<OptionItem<T>> LabeledValues<T>(params (T Value, string Label)[] items) where T : struct, Enum
{
return items.Select(item => new OptionItem<T>(item.Value, item.Label)).ToList();
}
private static string StatusLabel(NotificationStatus status) => status switch
{
NotificationStatus.Displayed => "已显示",
NotificationStatus.DndMuted => "免打扰",
NotificationStatus.IllegalChannel => "无效频道",
NotificationStatus.System => "系统",
_ => status.ToString()
};
private sealed record OptionItem<T>(T Value, string Label) where T : struct, Enum;
}