feat(app): 初始化本地通知桌面应用
搭建 .NET 8 WPF 应用骨架,加入系统托盘、单实例启动与主控制面板。 实现本地 HTTP /notify 消息接入、频道严格匹配、免打扰、熔断限流与历史持久化。 补充弹窗样式配置、队列/推挤/替换展示、溢出处理、应用图标和项目文档。 Initial-Commit: true
This commit is contained in:
372
MainWindow.xaml.cs
Normal file
372
MainWindow.xaml.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user