Files
omni-notify/MainWindow.xaml.cs
gamewhale 21fd8eaf55 release: 发布 v0.2.0
本次发布聚焦降低误报风险、规范发布产物和重构仓库文档。

主要变更:

- 删除开机自启功能,移除 Windows 启动项注册表写入逻辑。

- 新增标准应用清单,声明应用以普通用户权限运行。

- 新增 framework-dependent 发布脚本,保持发布包不内置 .NET 运行时。

- 禁用单文件、自解压、裁剪和 ReadyToRun 发布方式,保持产物结构透明。

- 将发布资产命名规范化为 omni-notify-v0.2.0-win-x64.zip。

- 重写文档结构:README.md 面向用户,docs/development.md 面向开发者。

- 删除过时且内容重复的 PRD.md 与 docs/usage.md。

验证:

- dotnet build .\OmniNotify.csproj -c Release 构建通过。

- 发布脚本成功生成 v0.2.0 win-x64 压缩包。

- 发布目录未包含 coreclr.dll、hostfxr.dll、hostpolicy.dll 等 .NET 运行时文件。
2026-05-20 13:01:41 +08:00

370 lines
14 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()
{
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.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);
}
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;
}