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