diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 35916bc0d1c..3e8202eb2cb 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 9.6.4 + 9.6.14 diff --git a/src/BootstrapBlazor/Components/BaseComponents/BootstrapModuleComponentBase.cs b/src/BootstrapBlazor/Components/BaseComponents/BootstrapModuleComponentBase.cs index 5b1ba65b30a..ac8abc23429 100644 --- a/src/BootstrapBlazor/Components/BaseComponents/BootstrapModuleComponentBase.cs +++ b/src/BootstrapBlazor/Components/BaseComponents/BootstrapModuleComponentBase.cs @@ -106,7 +106,7 @@ protected virtual void OnLoadJSModule() /// call JavaScript method /// /// - protected virtual Task InvokeInitAsync() => InvokeVoidAsync("init", Id); + protected virtual Task InvokeInitAsync() => InvokeVoidAsync("init", Id, Interop); /// /// call JavaScript method diff --git a/src/BootstrapBlazor/Components/BaseComponents/DynamicElement.cs b/src/BootstrapBlazor/Components/BaseComponents/DynamicElement.cs index 53f30af13e3..39bde4e177b 100644 --- a/src/BootstrapBlazor/Components/BaseComponents/DynamicElement.cs +++ b/src/BootstrapBlazor/Components/BaseComponents/DynamicElement.cs @@ -80,6 +80,12 @@ public class DynamicElement : BootstrapComponentBase [Parameter] public bool GenerateElement { get; set; } = true; + /// + /// 获得/设置 组件 Key 值 + /// + [Parameter] + public object? Key { get; set; } + /// /// BuildRenderTree 方法 /// @@ -119,6 +125,11 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(8, ChildContent); + if (Key != null) + { + builder.SetKey(Key); + } + if (GenerateElement || IsTriggerClick() || IsTriggerDoubleClick()) { builder.CloseElement(); diff --git a/src/BootstrapBlazor/Components/Drawer/Drawer.razor.js b/src/BootstrapBlazor/Components/Drawer/Drawer.razor.js index a9c72fb3e40..064197cd6e7 100644 --- a/src/BootstrapBlazor/Components/Drawer/Drawer.razor.js +++ b/src/BootstrapBlazor/Components/Drawer/Drawer.razor.js @@ -9,7 +9,7 @@ const initDrag = el => { let height = 0; let isVertical = false; const drawerBody = el.querySelector('.drawer-body'); - const bar = el.querySelector('.drawer-bar'); + const bar = [...drawerBody.children].find(i => i.classList.contains('drawer-bar')); Drag.drag(bar, e => { isVertical = drawerBody.classList.contains("top") || drawerBody.classList.contains("bottom") @@ -105,7 +105,7 @@ export function execute(id, open) { showDrawer(); } } - + const showDrawer = () => { drawerBody.classList.add('show'); if (drawerBackdrop) { @@ -175,7 +175,8 @@ export function dispose(id) { body.classList.remove('overflow-hidden') } - const bar = el.querySelector('.drawer-bar'); + const drawerBody = el.querySelector('.drawer-body'); + const bar = [...drawerBody.children].find(i => i.classList.contains('drawer-bar')); if (bar) { Drag.dispose(bar) } diff --git a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor index b9e7aa6737e..caec88b1d50 100644 --- a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor +++ b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor @@ -1,6 +1,6 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @inherits ValidateBase -@attribute [BootstrapModuleAutoLoader] +@attribute [BootstrapModuleAutoLoader(JSObjectReference = true)] @if (IsShowLabel) { diff --git a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.cs b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.cs index c3150784301..9718c07b6bb 100644 --- a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.cs +++ b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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 @@ -21,7 +21,7 @@ public partial class IpAddress /// /// 获得 class 样式集合 /// - protected string? ClassName => CssBuilder.Default("ipaddress form-control") + protected string? ClassName => CssBuilder.Default("bb-ip form-control") .AddClass("disabled", IsDisabled) .AddClass(CssClass).AddClass(ValidCss) .Build(); @@ -62,7 +62,6 @@ private void ValueChanged1(ChangeEventArgs args) { Value1 = Value1[0..3]; } - UpdateValue(); } @@ -112,4 +111,22 @@ private void UpdateValue() { CurrentValueAsString = $"{Value1}.{Value2}.{Value3}.{Value4}"; } + + /// + /// 更新 值方法供 JS 调用 + /// + /// + /// + /// + /// + [JSInvokable] + public void TriggerUpdate(int v1, int v2, int v3, int v4) + { + Value1 = v1.ToString(); + Value2 = v2.ToString(); + Value3 = v3.ToString(); + Value4 = v4.ToString(); + + UpdateValue(); + } } diff --git a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.js b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.js index c24cfa78eea..f97b93777cf 100644 --- a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.js +++ b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.js @@ -1,4 +1,4 @@ -import Data from "../../modules/data.js" +import Data from "../../modules/data.js" import EventHandler from "../../modules/event-handler.js" const selectCell = (el, index) => { @@ -13,7 +13,7 @@ const selectCell = (el, index) => { return c } -export function init(id) { +export function init(id, invoke) { const el = document.getElementById(id) if (el === null) { return @@ -25,7 +25,6 @@ export function init(id) { el.querySelectorAll(".ipv4-cell").forEach((c, index) => { EventHandler.on(c, 'keydown', e => { if ((e.keyCode >= 48 && e.keyCode <= 57) || (e.keyCode >= 96 && e.keyCode <= 105)) { - // numbers, backup last status ip.prevValues[index] = c.value if (c.value === "0") { c.value = "" @@ -56,7 +55,15 @@ export function init(id) { selectCell(el, index - 1) } } - else if (e.key === 'Delete' || e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { + else if (current.selectionStart === current.value.length && (e.key === 'Space' || e.key === 'ArrowRight')) { + e.preventDefault() + selectCell(el, index + 1) + } + else if (current.selectionStart === 0 && e.key === 'ArrowLeft') { + e.preventDefault() + selectCell(el, index - 1) + } + else if (e.composed || e.key === 'Delete' || e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { } else { @@ -72,6 +79,37 @@ export function init(id) { } } }) + + EventHandler.on(c, 'paste', e => { + e.preventDefault(); + const raw = (e.clipboardData || window.clipboardData)?.getData('text') ?? ''; + if (!raw) { + return; + } + + const ipRegex = /\b((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/; + const match = raw.match(ipRegex); + const parts = match ? match[0] : null; + if (parts === null) { + return; + } + + const cells = el.querySelectorAll(".ipv4-cell"); + let pos = 0; + const args = []; + parts.split('.').forEach(p => { + if (pos > 3) { + return; + } + const num = parseInt(p, 10); + args.push(num); + cells[pos].value = num.toString(); + ip.prevValues[pos] = cells[pos].value; + pos++; + }); + + invoke.invokeMethodAsync("TriggerUpdate", ...args); + }); }) } diff --git a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.scss b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.scss index 3f002ee43d6..a57ba28e86b 100644 --- a/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.scss +++ b/src/BootstrapBlazor/Components/IpAddress/IpAddress.razor.scss @@ -1,4 +1,4 @@ -.ipaddress { +.bb-ip { --bb-ip-cell-max-width: #{$bb-ip-cell-max-width}; display: flex; flex-wrap: nowrap; diff --git a/src/BootstrapBlazor/Components/Table/Table.razor b/src/BootstrapBlazor/Components/Table/Table.razor index 80d1bc0bed6..9687f30c3d0 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor +++ b/src/BootstrapBlazor/Components/Table/Table.razor @@ -237,7 +237,7 @@ } else { - ; RenderFragment RenderRow => item => - @ SortName != col.GetFieldName( [Parameter] public bool ShowColumnWidthTooltip { get; set; } + /// + /// 获得/设置 行 Key 回调方法 + /// + [Parameter] + public Func? OnGetRowKey { get; set; } + private string ScrollWidthString => $"width: {ActualScrollWidth}px;"; private string? GetScrollStyleString(bool condition) => condition @@ -1667,6 +1673,8 @@ private void OnTouchEnd() TouchStart = false; } + private object? GetKeyByITem(TItem item) => OnGetRowKey?.Invoke(item); + /// /// Dispose 方法 /// diff --git a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs index 58a5c28161a..dd90e419123 100644 --- a/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs +++ b/src/BootstrapBlazor/Components/TreeView/TreeView.razor.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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 @@ -152,6 +152,12 @@ public partial class TreeView : IModelEqualityComparer [Parameter] public Func, Task>? OnTreeItemClick { get; set; } + /// + /// 获得/设置 点击节点前回调方法 + /// + [Parameter] + public Func, Task>? OnBeforeTreeItemClick { get; set; } + /// /// Gets or sets the callback method when a tree item is checked. /// @@ -545,6 +551,15 @@ private async Task>> GetChildrenRowAsync(Tree private async Task OnClick(TreeViewItem item) { + if (OnBeforeTreeItemClick != null) + { + var ret = await OnBeforeTreeItemClick(item); + if (ret == false) + { + return; + } + } + _activeItem = item; if (ClickToggleNode && item.CanTriggerClickNode(IsDisabled, CanExpandWhenDisabled)) { diff --git a/src/BootstrapBlazor/Components/Upload/CardUpload.razor b/src/BootstrapBlazor/Components/Upload/CardUpload.razor index 9a02c28b95f..d05c7fb09f1 100644 --- a/src/BootstrapBlazor/Components/Upload/CardUpload.razor +++ b/src/BootstrapBlazor/Components/Upload/CardUpload.razor @@ -16,13 +16,13 @@ {
- @if (IsImage(item)) + @if (IconTemplate != null) { - prevUrl + @IconTemplate(item) } - else if (IconTemplate != null) + else if (IsImage(item)) { - @IconTemplate(item) + prevUrl } else { @@ -32,6 +32,10 @@
@item.GetFileName() (@item.Size.ToFileSizeString())
+ @if (ActionButtonTemplate != null) + { + @ActionButtonTemplate(item) + } @if (ShowZoomButton) {
/// url - Task PdfDataAsync(string url); + /// + Task PdfDataAsync(string url, PdfOptions? options = null); /// /// Export method /// /// url - Task PdfStreamAsync(string url); + /// + Task PdfStreamAsync(string url, PdfOptions? options = null); /// /// Export method @@ -28,7 +30,8 @@ public interface IHtml2Pdf /// html raw string /// /// - Task PdfDataFromHtmlAsync(string html, IEnumerable? links = null, IEnumerable? scripts = null); + /// + Task PdfDataFromHtmlAsync(string html, IEnumerable? links = null, IEnumerable? scripts = null, PdfOptions? options = null); /// /// Export method @@ -36,5 +39,6 @@ public interface IHtml2Pdf /// html raw string /// /// - Task PdfStreamFromHtmlAsync(string html, IEnumerable? links = null, IEnumerable? scripts = null); + /// + Task PdfStreamFromHtmlAsync(string html, IEnumerable? links = null, IEnumerable? scripts = null, PdfOptions? options = null); } diff --git a/src/BootstrapBlazor/Services/IZipArchiveService.cs b/src/BootstrapBlazor/Services/IZipArchiveService.cs index 2514f9b6c17..1bcdf2f33a6 100644 --- a/src/BootstrapBlazor/Services/IZipArchiveService.cs +++ b/src/BootstrapBlazor/Services/IZipArchiveService.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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 @@ -16,39 +16,39 @@ public interface IZipArchiveService /// /// 将文件归档方法 /// - /// 要归档的文件集合 + /// 要归档的文件集合 /// 归档配置 /// 归档数据流 - Task ArchiveAsync(IEnumerable files, ArchiveOptions? options = null); + Task ArchiveAsync(IEnumerable entries, ArchiveOptions? options = null); /// /// 将文件归档方法 /// - /// 归档文件 - /// 要归档的文件集合 + /// 归档文件 + /// 要归档的文件集合 /// 归档配置 - Task ArchiveAsync(string archiveFileName, IEnumerable files, ArchiveOptions? options = null); + Task ArchiveAsync(string archiveFile, IEnumerable entries, ArchiveOptions? options = null); /// /// 将指定目录归档方法 /// - /// 归档文件 + /// 归档文件 /// 要归档文件夹 - /// - /// - /// - /// - Task ArchiveDirectory(string archiveFileName, string directoryName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool includeBaseDirectory = false, Encoding? encoding = null); + /// 压缩率 + /// 是否包含本目录 默认 false + /// 编码方式 默认 null 内部使用 UTF-8 + /// + Task ArchiveDirectoryAsync(string archiveFile, string directoryName, CompressionLevel compressionLevel = CompressionLevel.Optimal, bool includeBaseDirectory = false, Encoding? encoding = null, CancellationToken token = default); /// - /// 解压缩归档文件到指定文件夹 + /// 解压缩归档文件到指定文件夹异步方法 /// /// 归档文件 /// 解压缩文件夹 /// 是否覆盖文件 默认 false 不覆盖 /// 编码方式 默认 null 内部使用 UTF-8 - /// - bool ExtractToDirectory(string archiveFile, string destinationDirectoryName, bool overwriteFiles = false, Encoding? encoding = null); + /// + Task ExtractToDirectoryAsync(string archiveFile, string destinationDirectoryName, bool overwriteFiles = false, Encoding? encoding = null, CancellationToken token = default); /// /// 获得归档压缩文件中指定归档文件 @@ -57,6 +57,26 @@ public interface IZipArchiveService /// 解压缩文件 /// 是否覆盖文件 默认 false 不覆盖 /// 编码方式 默认 null 内部使用 UTF-8 - /// ZipArchiveEntry? GetEntry(string archiveFile, string entryFile, bool overwriteFiles = false, Encoding? encoding = null); } + +/// +/// 归档项实体类 +/// +public readonly record struct ArchiveEntry +{ + /// + /// 获得 物理文件 + /// + public string SourceFileName { get; init; } + + /// + /// 获得 归档项 + /// + public string EntryName { get; init; } + + /// + /// 获得 压缩配置 + /// + public CompressionLevel? CompressionLevel { get; init; } +} diff --git a/test/UnitTest/Components/IpAddressTest.cs b/test/UnitTest/Components/IpAddressTest.cs index 55e143cefc6..02d6383a169 100644 --- a/test/UnitTest/Components/IpAddressTest.cs +++ b/test/UnitTest/Components/IpAddressTest.cs @@ -11,7 +11,7 @@ public class IpAddressTest : BootstrapBlazorTestBase public async Task IpAddress_Ok() { var cut = Context.RenderComponent(); - cut.Contains("ipaddress form-control"); + cut.Contains("bb-ip form-control"); Assert.Equal("0.0.0.0", cut.Instance.Value); var inputs = cut.FindAll(".ipv4-cell"); @@ -46,6 +46,14 @@ public async Task IpAddress_Value() Assert.Equal("123.123.123.123", cut.Instance.Value); } + [Fact] + public async Task TriggerUpdate_Ok() + { + var cut = Context.Render(); + await cut.InvokeAsync(() => cut.Instance.TriggerUpdate(192, 0, 1, 10)); + Assert.Equal("192.0.1.10", cut.Instance.Value); + } + [Fact] public void ValidateForm_Ok() { diff --git a/test/UnitTest/Services/ZipArchiveServiceTest.cs b/test/UnitTest/Services/ZipArchiveServiceTest.cs index b4dd9caae67..0e5bd852e74 100644 --- a/test/UnitTest/Services/ZipArchiveServiceTest.cs +++ b/test/UnitTest/Services/ZipArchiveServiceTest.cs @@ -1,8 +1,10 @@ -// Licensed to the .NET Foundation under one or more agreements. +// 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 System.IO.Compression; + namespace UnitTest.Services; public class ZipArchiveServiceTest : BootstrapBlazorTestBase @@ -22,10 +24,15 @@ public async Task Archive_Ok() using var fs = File.OpenWrite(f); fs.WriteByte(65); }); - var stream = await archService.ArchiveAsync(files); + var items = files.Select(i => new ArchiveEntry() + { + SourceFileName = i, + EntryName = Path.GetFileName(i) + }); + var stream = await archService.ArchiveAsync(items); Assert.NotNull(stream); - stream = await archService.ArchiveAsync(files, new ArchiveOptions() + stream = await archService.ArchiveAsync(items, new ArchiveOptions() { CompressionLevel = System.IO.Compression.CompressionLevel.Optimal, Encoding = System.Text.Encoding.UTF8, @@ -35,7 +42,7 @@ public async Task Archive_Ok() Assert.NotNull(stream); var archiveFile = Path.Combine(root, "test.zip"); - await archService.ArchiveAsync(archiveFile, files); + await archService.ArchiveAsync(archiveFile, items); Assert.True(File.Exists(archiveFile)); // GetEntry @@ -48,7 +55,15 @@ public async Task Archive_Ok() { Directory.Delete(destFolder, true); } - archService.ExtractToDirectory(archiveFile, destFolder); + await archService.ExtractToDirectoryAsync(archiveFile, destFolder); + Assert.True(Directory.Exists(destFolder)); + + // 删除文件夹 + Directory.Delete(destFolder, true); + Assert.False(Directory.Exists(destFolder)); + + // 异步解压缩单元测试 + await archService.ExtractToDirectoryAsync(archiveFile, destFolder); Assert.True(Directory.Exists(destFolder)); // 打包文件夹单元测试 @@ -62,10 +77,83 @@ public async Task Archive_Ok() { File.Delete(destFile); } - await archService.ArchiveDirectory(destFile, destFolder, includeBaseDirectory: true); + await archService.ArchiveDirectoryAsync(destFile, destFolder, includeBaseDirectory: true); Assert.True(File.Exists(destFile)); File.Delete(destFile); - await Assert.ThrowsAsync(() => archService.ArchiveDirectory(null!, destFolder, includeBaseDirectory: true)); + await Assert.ThrowsAsync(() => archService.ArchiveDirectoryAsync(null!, destFolder, includeBaseDirectory: true)); + } + + [Fact] + public async Task ZipArchive_Ok() + { + var fileName = Path.Combine(AppContext.BaseDirectory, "test", "3.zip"); + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + + using var fs = File.OpenWrite(fileName); + using var zip = new ZipArchive(fs, ZipArchiveMode.Create); + + var item = Path.Combine(AppContext.BaseDirectory, "test", "1.txt"); + zip.CreateEntry("text/"); + await zip.CreateEntryFromFileAsync(item, "text/1.txt"); + } + + [Fact] + public async Task ArchiveAsync_Ok() + { + var fileName = Path.Combine(AppContext.BaseDirectory, "archive_test", "test.zip"); + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + + var root = AppContext.BaseDirectory; + var files = new string[] + { + Path.Combine(root, "archive_test", "test1", "1.txt"), + Path.Combine(root, "archive_test", "test2", "2.txt") + }; + files.ToList().ForEach(f => + { + var folder = Path.GetDirectoryName(f); + if (!string.IsNullOrEmpty(folder) && !Directory.Exists(folder)) + { + Directory.CreateDirectory(folder); + } + using var fs = File.OpenWrite(f); + fs.WriteByte(65); + }); + + var archService = Context.Services.GetRequiredService(); + await archService.ArchiveAsync(fileName, new List() + { + new ArchiveEntry() + { + SourceFileName = files[0], + EntryName = "test1/test.log" + }, + new ArchiveEntry() + { + SourceFileName = files[1], + EntryName = "test2/test.log", + CompressionLevel = CompressionLevel.Optimal + }, + new ArchiveEntry() + { + SourceFileName = Path.Combine(AppContext.BaseDirectory, "archive_test", "test1"), + EntryName = "test1", + }, + new ArchiveEntry() + { + SourceFileName = Path.Combine(AppContext.BaseDirectory, "archive_test", "test1"), + EntryName = "test2", + CompressionLevel = CompressionLevel.Optimal + } + }); + + Assert.True(File.Exists(fileName)); } }