diff --git a/src/BootstrapBlazor.Server/Components/Layout/HomeLayout.razor b/src/BootstrapBlazor.Server/Components/Layout/HomeLayout.razor index d38bedbe44c..139cca04eb4 100644 --- a/src/BootstrapBlazor.Server/Components/Layout/HomeLayout.razor +++ b/src/BootstrapBlazor.Server/Components/Layout/HomeLayout.razor @@ -64,6 +64,9 @@
BB @VersionService.Version
+
+ +
@Localizer["Footer"] diff --git a/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitor.cs b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitor.cs new file mode 100644 index 00000000000..5150d24aa88 --- /dev/null +++ b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitor.cs @@ -0,0 +1,69 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// 客户端链接组件 +/// +[BootstrapModuleAutoLoader(ModuleName = "net", JSObjectReference = true)] +public class NetworkMonitor : BootstrapModuleComponentBase +{ + /// + /// Gets or sets the callback function that is invoked when the network state changes. + /// + [Parameter] + public Func? OnNetworkStateChanged { get; set; } + + /// + /// Gets or sets the list of indicators used for display info. + /// + [Parameter] + public List? Indicators { get; set; } + + private NetworkMonitorState _state = new(); + + /// + /// + /// + /// + protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, new + { + Invoke = Interop, + OnlineStateChangedCallback = nameof(TriggerOnlineStateChanged), + OnNetworkStateChangedCallback = nameof(TriggerNetworkStateChanged), + Indicators + }); + + /// + /// JSInvoke 回调方法 + /// + /// + [JSInvokable] + public async Task TriggerOnlineStateChanged(bool online) + { + _state.IsOnline = online; + if (OnNetworkStateChanged != null) + { + await OnNetworkStateChanged(_state); + } + } + + /// + /// JSInvoke 回调方法 + /// + /// + [JSInvokable] + public async Task TriggerNetworkStateChanged(NetworkMonitorState state) + { + // 网络状态变化回调方法 + _state = state; + _state.IsOnline = true; + if (OnNetworkStateChanged != null) + { + await OnNetworkStateChanged(_state); + } + } +} diff --git a/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor new file mode 100644 index 00000000000..0cf96a79e14 --- /dev/null +++ b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor @@ -0,0 +1,26 @@ +@namespace BootstrapBlazor.Components +@inherits IdComponentBase + + + + + + + + + diff --git a/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor.cs b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor.cs new file mode 100644 index 00000000000..5020815fbc8 --- /dev/null +++ b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Localization; + +namespace BootstrapBlazor.Components; + +/// +/// Represents a network monitor indicator with customizable tooltip settings. +/// +/// This component allows you to configure the text, placement, and trigger behavior of a tooltip that +/// appears when interacting with the network monitor indicator. The tooltip can be customized to provide additional +/// information to users. +public partial class NetworkMonitorIndicator +{ + /// + /// 获得/设置 Popover 弹窗标题 默认为 null + /// + [Parameter] + public string? Title { get; set; } + + /// + /// 获得/设置 Popover 显示位置 默认为 Top + /// + [Parameter] + public Placement PopoverPlacement { get; set; } = Placement.Top; + + /// + /// 获得/设置 Popover 触发方式 默认为 hover focus + /// + [Parameter] + [NotNull] + public string? Trigger { get; set; } + + [Inject, NotNull] + private IStringLocalizer? Localizer { get; set; } + + private NetworkMonitorState _state = new(); + private readonly List _indicators = []; + private string _networkTypeString = ""; + private string _downlinkString = ""; + private string _rttString = ""; + + private string? ClassString => CssBuilder.Default("bb-nt-indicator") + .AddClass("bb-nt-indicator-4g", _state.NetworkType == "4g") + .AddClass("bb-nt-indicator-3g", _state.NetworkType == "3g") + .AddClass("bb-nt-indicator-2g", _state.NetworkType == "2g") + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + /// + /// + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + _indicators.Add(Id); + } + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + Trigger ??= "hover focus"; + Title ??= Localizer["Title"]; + _networkTypeString = Localizer["NetworkType"]; + _downlinkString = Localizer["Downlink"]; + _rttString = Localizer["RTT"]; + } + + private Task OnNetworkStateChanged(NetworkMonitorState state) + { + _state = state; + StateHasChanged(); + return Task.CompletedTask; + } +} diff --git a/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor.scss b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor.scss new file mode 100644 index 00000000000..9d6546064c8 --- /dev/null +++ b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorIndicator.razor.scss @@ -0,0 +1,52 @@ +.bb-nt-indicator { + --bb-nt-indicator-width: .5rem; + --bb-nt-indicator-border-radius: 50%; + --bb-nt-indicator-bg: var(--bs-secondary); + background: var(--bb-nt-indicator-bg); + cursor: pointer; + width: var(--bb-nt-indicator-width); + height: var(--bb-nt-indicator-width); + border-radius: var(--bb-nt-indicator-border-radius); + display: inline-block; + + &.bb-nt-indicator-4g { + background-color: var(--bs-success); + } + + &.bb-nt-indicator-3g { + background-color: var(--bs-warning); + } + + &.bb-nt-indicator-2g { + background-color: var(--bs-danger); + } + + &.offline { + background-color: var(--bs-secondary); + } +} + +[data-bs-toggle="popover"]:has(.offline) { + pointer-events: none; +} + +.bb-nt-main { + .bb-nt-item { + display: flex; + align-items: center; + + > span { + width: 120px; + } + + > div { + flex: 1; + min-width: 0; + width: 1%; + } + + &:not(:last-child) { + margin-bottom: .5rem; + } + } +} diff --git a/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorState.cs b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorState.cs new file mode 100644 index 00000000000..f6c4afdcbf9 --- /dev/null +++ b/src/BootstrapBlazor/Components/NetworkMonitor/NetworkMonitorState.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace BootstrapBlazor.Components; + +/// +/// 网络状态信息类 +/// +public class NetworkMonitorState +{ + /// + /// Gets or sets a value indicating whether the network is online + /// + public bool IsOnline { get; set; } + + /// + /// Gets or sets the current network type + /// + public string? NetworkType { get; set; } + + /// + /// Gets or sets the downlink speed in megabits per second (Mbps). + /// + public double? Downlink { get; set; } + + /// + /// Gets or sets the round-trip time (RTT) in milliseconds. + /// + public int RTT { get; set; } +} diff --git a/src/BootstrapBlazor/Locales/en.json b/src/BootstrapBlazor/Locales/en.json index bab4144d7ae..f68bd3852cb 100644 --- a/src/BootstrapBlazor/Locales/en.json +++ b/src/BootstrapBlazor/Locales/en.json @@ -389,5 +389,11 @@ }, "BootstrapBlazor.Components.ValidateBase": { "DefaultRequiredErrorMessage": "{0} is required." + }, + "BootstrapBlazor.Components.NetworkMonitorIndicator": { + "NTitle": "Network", + "NetworkType": "NetworkType", + "Downlink": "Downlink", + "RTT": "RTT" } } diff --git a/src/BootstrapBlazor/Locales/zh.json b/src/BootstrapBlazor/Locales/zh.json index 043e283ba57..88d10375a7a 100644 --- a/src/BootstrapBlazor/Locales/zh.json +++ b/src/BootstrapBlazor/Locales/zh.json @@ -389,5 +389,11 @@ }, "BootstrapBlazor.Components.ValidateBase": { "DefaultRequiredErrorMessage": "{0}是必填项" + }, + "BootstrapBlazor.Components.NetworkMonitorIndicator": { + "Title": "网络状态", + "NetworkType": "网络类型", + "Downlink": "下载速度", + "RTT": "响应时间" } } diff --git a/src/BootstrapBlazor/wwwroot/modules/net.js b/src/BootstrapBlazor/wwwroot/modules/net.js new file mode 100644 index 00000000000..a7748f7c1ae --- /dev/null +++ b/src/BootstrapBlazor/wwwroot/modules/net.js @@ -0,0 +1,58 @@ +import Data from "./data.js" +import EventHandler from "./event-handler.js"; + +export function init(id, options) { + const { invoke, onlineStateChangedCallback, onNetworkStateChangedCallback, indicators } = options; + const updateState = nt => { + const { downlink, effectiveType, rtt } = nt; + invoke.invokeMethodAsync(onNetworkStateChangedCallback, { + downlink, networkType: effectiveType, rTT: rtt + }); + } + navigator.connection.onchange = e => { + updateState(e.target); + } + + const onlineStateChanged = () => { + if (Array.isArray(indicators)) { + indicators.forEach(indicator => { + const el = document.getElementById(indicator); + if (el) { + el.classList.remove('offline'); + } + }); + } + invoke.invokeMethodAsync(onlineStateChangedCallback, true); + } + const offlineStateChanged = () => { + if (Array.isArray(indicators)) { + indicators.forEach(indicator => { + const el = document.getElementById(indicator); + if (el) { + el.classList.add('offline'); + } + }); + } + invoke.invokeMethodAsync(onlineStateChangedCallback, false); + } + EventHandler.on(window, 'online', onlineStateChanged); + EventHandler.on(window, 'offline', offlineStateChanged); + + Data.set(id, { + onlineStateChanged, + offlineStateChanged + }); + + updateState(navigator.connection); +} + +export async function dispose(id) { + const nt = Data.get(id); + Data.remove(id); + + if (nt) { + const { onlineStateChanged, offlineStateChanged } = nt; + EventHandler.off(window, 'online', onlineStateChanged); + EventHandler.off(window, 'offline', offlineStateChanged); + } +} diff --git a/src/BootstrapBlazor/wwwroot/scss/components.scss b/src/BootstrapBlazor/wwwroot/scss/components.scss index da15073286f..bcbd0818c41 100644 --- a/src/BootstrapBlazor/wwwroot/scss/components.scss +++ b/src/BootstrapBlazor/wwwroot/scss/components.scss @@ -69,6 +69,7 @@ @import "../../Components/Menu/Menu.razor.scss"; @import "../../Components/Message/Message.razor.scss"; @import "../../Components/Modal/Modal.razor.scss"; +@import "../../Components/NetworkMonitor/NetworkMonitorIndicator.razor.scss"; @import "../../Components/Pagination/Pagination.razor.scss"; @import "../../Components/Popover/Popover.razor.scss"; @import "../../Components/QueryBuilder/QueryBuilder.razor.scss"; diff --git a/test/UnitTest/Components/DisplayTest.cs b/test/UnitTest/Components/DisplayTest.cs index 35dd8e71166..6504484b12c 100644 --- a/test/UnitTest/Components/DisplayTest.cs +++ b/test/UnitTest/Components/DisplayTest.cs @@ -56,7 +56,7 @@ public async Task LookupService_Ok() { pb.Add(a => a.LookupService, new MockLookupService()); }); - await Task.Delay(50); + await Task.Delay(100); Assert.Contains("Test1,Test2", cut.Markup); cut.SetParametersAndRender(pb => @@ -64,7 +64,7 @@ public async Task LookupService_Ok() pb.Add(a => a.LookupServiceKey, null); pb.Add(a => a.Lookup, new List { new("v1", "Test3"), new("v2", "Test4") }); }); - await Task.Delay(50); + await Task.Delay(100); Assert.Contains("Test3,Test4", cut.Markup); } diff --git a/test/UnitTest/Components/NetworkMonitorIndicatorTest.cs b/test/UnitTest/Components/NetworkMonitorIndicatorTest.cs new file mode 100644 index 00000000000..ece9ca562eb --- /dev/null +++ b/test/UnitTest/Components/NetworkMonitorIndicatorTest.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +namespace UnitTest.Components; + +public class NetworkMonitorIndicatorTest : BootstrapBlazorTestBase +{ + [Fact] + public async Task NetworkMonitorIndicator_Ok() + { + var cut = Context.RenderComponent(pb => + { + pb.Add(a => a.PopoverPlacement, Placement.Top); + }); + + var com = cut.FindComponent(); + await cut.InvokeAsync(() => com.Instance.TriggerNetworkStateChanged(new NetworkMonitorState + { + IsOnline = false, + NetworkType = "4g", + Downlink = 10.0, + RTT = 50 + })); + Assert.DoesNotContain("offline", cut.Markup); + + await cut.InvokeAsync(() => com.Instance.TriggerOnlineStateChanged(true)); + Assert.DoesNotContain("offline", cut.Markup); + } + + [Fact] + public async Task NetworkMonitor_Ok() + { + NetworkMonitorState? state = null; + var cut = Context.RenderComponent(pb => + { + pb.Add(a => a.OnNetworkStateChanged, v => + { + state = v; + return Task.CompletedTask; + }); + }); + + await cut.InvokeAsync(() => cut.Instance.TriggerOnlineStateChanged(false)); + Assert.NotNull(state); + Assert.False(state.IsOnline); + } +}