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

Initial-Commit: true
2026-05-19 01:32:41 +08:00

373 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}