From 72ffffabc2cd18f87264e3aec1fd45ec1b66b752 Mon Sep 17 00:00:00 2001 From: Tony996 <6914529@qq.com> Date: Fri, 12 Jun 2026 16:19:40 +0800 Subject: [PATCH 01/44] =?UTF-8?q?Table=E7=BB=84=E4=BB=B6=E8=83=BD=E5=90=A6?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=A8=E6=80=81=E8=AE=BE=E7=BD=AE=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=88=97=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 可以动态设置固定列,同时固定列支持表格持久化 --- .../Components/Table/Table.razor.cs | 26 +++++++++- .../Components/Table/Table.razor.js | 20 ++++++-- .../Components/Table/TableColumnState.cs | 6 +++ test/UnitTest/Components/TableTest.cs | 49 +++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 8b71d314d81..328f6ee3b35 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1435,6 +1435,7 @@ private void ResetTableColumns() { var item = _tableColumnStates[index]; var col = columnMap[item.Name]; + col.Fixed = !string.IsNullOrEmpty(item.Name) && stateMap.TryGetValue(item.Name, out var state) ? stateMap[item.Name].Fixed : false; if (item.Visible) { // 增加到可见列缓存集合 @@ -1449,7 +1450,8 @@ private void ResetTableColumns() DisplayName = col.GetDisplayName(), Name = col.GetFieldName(), Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width, - Visible = col.GetVisible(_screenSize) + Visible = col.GetVisible(_screenSize), + Fixed = col.Fixed }; private async Task OnTableRenderAsync(bool firstRender) @@ -1940,6 +1942,28 @@ public async Task FitAllColumnWidth() await InvokeVoidAsync("fitAllColumnWidth", Id); } + /// + /// 更新表格列客户端状态方法 + /// Update Table Column Client Status Method + /// + /// + public async Task UpdateTableColumnClientStatus() + { + if (Columns.Count != 0) + { + // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 + foreach (var item in Columns) + { + _tableColumnStates.Find(x => x.Name == item.GetFieldName())!.Fixed = item.Fixed; + } + StateHasChanged(); + } + // 如果启用了 ClientTableName 则更新浏览器持久化列状态 + await InvokeVoidAsync("updateColumnStates", Id); + + return _tableColumnStateCache; + } + /// /// 清除表格列客户端状态实例方法 /// clear table column client status instance method diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 94804c8322e..48f02cfef78 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -586,7 +586,7 @@ const autoFitColumnWidth = async (table, col) => { const index = indexOfCol(col); let rows = null; let maxWidth = getColumnMaxCellWidth(table, index); - + if (table.options.fitColumnWidthIncludeHeader) { const th = getColumnHeader(col); maxWidth = Math.max(maxWidth, getCellWidth(th)); @@ -936,6 +936,18 @@ const removeColumnWidthState = tableName => { localStorage.removeItem(columnWidthKey); } +export function updateColumnStates(id) { + const el = document.getElementById(id) + if (el === null) { + return + } + let table = Data.get(id) + table.el = el; + Data.set(id, table) + const state = getColumnStateObject(table); + saveColumnStateToLocalstorage(table, state); + +} export function clearColumnStates(tableName) { const columnStateKey = `bb-table-${tableName}`; localStorage.removeItem(columnStateKey); @@ -976,7 +988,8 @@ const getColumnStateObject = table => { return { name: col.name, width: getColumnWidth(col, table.columns), - visible: col.visible + visible: col.visible, + fixed: col.fixed } }), table: getTableWidth(table.tables[0]) @@ -988,7 +1001,8 @@ const getColumnStateObject = table => { return { name: getColumnName(col), width: getResizableColumnWidth(col), - visible: true + visible: true, + fixed: getColumnHeader(col).classList.contains('fixed') } }), table: getTableWidth(table.tables[0]) diff --git a/src/BootstrapBlazor/Components/Table/TableColumnState.cs b/src/BootstrapBlazor/Components/Table/TableColumnState.cs index 1aad8e442d8..3a2e181395a 100644 --- a/src/BootstrapBlazor/Components/Table/TableColumnState.cs +++ b/src/BootstrapBlazor/Components/Table/TableColumnState.cs @@ -38,4 +38,10 @@ public class TableColumnState /// Gets or sets column width /// public int? Width { get; set; } + + /// + /// 获得/设置 列固定 + /// Gets or sets column fixed + /// + public bool Fixed { get; set; } } diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 3f48b29f279..41329364b4e 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9018,6 +9018,55 @@ public async Task OnTableColumnClientStatusChanged_ResizeColumn_Ok() Assert.NotNull(clientState); } + [Fact] + public async Task UpdateTableColumnClientStatus_Ok() + { + var state = new TableColumnClientStatus(); + state.TableWidth = 220; + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100, Fixed = true }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120, Fixed = false }); + + Context.JSInterop.Setup("getColumnStates", "test_update").SetResult(state); + var invoker = Context.JSInterop.SetupVoid("clearColumnStates", "test_update"); + invoker.SetVoidResult(); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_update"); + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.AllowResizing, true); + pb.Add(a => a.OnQueryAsync, OnQueryAsync(localizer)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", "Name"); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 80); + builder.CloseComponent(); + + builder.OpenComponent>(0); + builder.AddAttribute(3, "Field", "Address"); + builder.AddAttribute(4, "FieldExpression", Utility.GenerateValueExpression(foo, "Address", typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + // 由于启用了客户端持久化 Name 列宽使用 100 而非 80 + var table = cut.FindComponent>(); + var colGroup = table.Find("colgroup"); + Assert.Contains("style=\"width: 100px;\"", colGroup.ToMarkup()); + Assert.Contains("style=\"width: 120px;\"", colGroup.ToMarkup()); + + + var status = await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); + Assert.Equal(true, status.Columns[0].Fixed); + Assert.Equal(state.Columns.Count, status.Columns.Count); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() { From 74435e64a1451bd800b3a94dc9e47d15d209a49a Mon Sep 17 00:00:00 2001 From: Tony996 <6914529@qq.com> Date: Fri, 12 Jun 2026 16:50:03 +0800 Subject: [PATCH 02/44] =?UTF-8?q?=E6=B8=85=E9=99=A4=E7=A9=BA=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E5=A2=9E=E5=8A=A0=E7=BC=93=E5=AD=98=E5=88=97=E5=8F=AF?= =?UTF-8?q?=E8=83=BD=E4=B8=BA=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:清除空行,增加缓存列可能为判断 --- src/BootstrapBlazor/Components/Table/Table.razor.cs | 6 +++++- src/BootstrapBlazor/Components/Table/Table.razor.js | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 328f6ee3b35..4f7045596f3 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1954,7 +1954,11 @@ public async Task UpdateTableColumnClientStatus() // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 foreach (var item in Columns) { - _tableColumnStates.Find(x => x.Name == item.GetFieldName())!.Fixed = item.Fixed; + var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); + if (columnState != null) + { + columnState.Fixed = item.Fixed; + } } StateHasChanged(); } diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 48f02cfef78..0de8d1c639b 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -946,7 +946,6 @@ export function updateColumnStates(id) { Data.set(id, table) const state = getColumnStateObject(table); saveColumnStateToLocalstorage(table, state); - } export function clearColumnStates(tableName) { const columnStateKey = `bb-table-${tableName}`; From d2b777569f73370f2b43489ee1ea52cca450b05a Mon Sep 17 00:00:00 2001 From: Tony996 <6914529@qq.com> Date: Fri, 12 Jun 2026 20:41:34 +0800 Subject: [PATCH 03/44] =?UTF-8?q?refactor:=20=E8=A1=A5=E5=85=85=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=9B=BA=E5=AE=9A=E5=88=97=E6=97=B6=EF=BC=8C=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E5=88=97=E5=AE=BD=E7=BB=99=E9=BB=98=E8=AE=A4=E5=88=97?= =?UTF-8?q?=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 补充动态固定列时,没有列宽给默认列宽,以防止无列宽时固定后,横向滑动出现错位 --- src/BootstrapBlazor/Components/Table/Table.razor.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 4f7045596f3..95695da3ec0 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1958,6 +1958,7 @@ public async Task UpdateTableColumnClientStatus() if (columnState != null) { columnState.Fixed = item.Fixed; + columnState.Width = item.Width.HasValue ? item.Width : (item.Fixed ? DefaultFixedColumnWidth : null); } } StateHasChanged(); From 02a3face8abcb188b16342b0f57cd35a2938941b Mon Sep 17 00:00:00 2001 From: Tony996 <6914529@qq.com> Date: Fri, 12 Jun 2026 23:16:50 +0800 Subject: [PATCH 04/44] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 调整指定固定列时,列宽必须有值 test:补充单元测试 --- .../Components/Table/Table.razor.cs | 2 +- test/UnitTest/Components/TableTest.cs | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 95695da3ec0..65cce099d97 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1958,7 +1958,7 @@ public async Task UpdateTableColumnClientStatus() if (columnState != null) { columnState.Fixed = item.Fixed; - columnState.Width = item.Width.HasValue ? item.Width : (item.Fixed ? DefaultFixedColumnWidth : null); + columnState.Width = item.Fixed && !item.Width.HasValue ? DefaultFixedColumnWidth : item.Width; } } StateHasChanged(); diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 41329364b4e..e610b54aae2 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9023,11 +9023,11 @@ public async Task UpdateTableColumnClientStatus_Ok() { var state = new TableColumnClientStatus(); state.TableWidth = 220; - state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100, Fixed = true }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Fixed = true }); state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120, Fixed = false }); Context.JSInterop.Setup("getColumnStates", "test_update").SetResult(state); - var invoker = Context.JSInterop.SetupVoid("clearColumnStates", "test_update"); + var invoker = Context.JSInterop.SetupVoid("updateColumnStates", "test_update"); invoker.SetVoidResult(); var localizer = Context.Services.GetRequiredService>(); @@ -9044,7 +9044,7 @@ public async Task UpdateTableColumnClientStatus_Ok() builder.OpenComponent>(0); builder.AddAttribute(1, "Field", "Name"); builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); - builder.AddAttribute(3, "Width", 80); + builder.AddAttribute(3, "Fixed", true); builder.CloseComponent(); builder.OpenComponent>(0); @@ -9058,13 +9058,23 @@ public async Task UpdateTableColumnClientStatus_Ok() // 由于启用了客户端持久化 Name 列宽使用 100 而非 80 var table = cut.FindComponent>(); var colGroup = table.Find("colgroup"); - Assert.Contains("style=\"width: 100px;\"", colGroup.ToMarkup()); Assert.Contains("style=\"width: 120px;\"", colGroup.ToMarkup()); - var status = await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); Assert.Equal(true, status.Columns[0].Fixed); Assert.Equal(state.Columns.Count, status.Columns.Count); + + table = cut.FindComponent>(); + var columns = cut.FindAll("th"); + colGroup = table.Find("colgroup"); + Assert.Contains("style=\"width: 200px;\"", colGroup.ToMarkup()); + if (columns[0].ClassName.Contains("fixed")) + { + var fixedWidth = cut.FindAll("col")[0].OuterHtml.Contains("width: 200px"); + Assert.Equal("fixedWidth:True", $"fixedWidth:{fixedWidth}"); + } + + } [Fact] From 33988986414bc92fba998f00fd5e4ee679427e6b Mon Sep 17 00:00:00 2001 From: Tony996 <6914529@qq.com> Date: Fri, 12 Jun 2026 23:20:20 +0800 Subject: [PATCH 05/44] =?UTF-8?q?=E6=B8=85=E9=99=A4=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E7=A9=BA=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test:清除多余空行 --- test/UnitTest/Components/TableTest.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index e610b54aae2..e88e00e0eaf 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9055,7 +9055,6 @@ public async Task UpdateTableColumnClientStatus_Ok() }); }); - // 由于启用了客户端持久化 Name 列宽使用 100 而非 80 var table = cut.FindComponent>(); var colGroup = table.Find("colgroup"); Assert.Contains("style=\"width: 120px;\"", colGroup.ToMarkup()); @@ -9073,8 +9072,6 @@ public async Task UpdateTableColumnClientStatus_Ok() var fixedWidth = cut.FindAll("col")[0].OuterHtml.Contains("width: 200px"); Assert.Equal("fixedWidth:True", $"fixedWidth:{fixedWidth}"); } - - } [Fact] From 09582044d773781135e69bd3db374dee42a5563c Mon Sep 17 00:00:00 2001 From: Tony <6914529@qq.com> Date: Tue, 7 Jul 2026 17:53:41 +0800 Subject: [PATCH 06/44] =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=88=97=E6=8C=81=E4=B9=85=E5=8C=96=E5=9B=9E=E6=9D=A5=E6=97=B6?= =?UTF-8?q?style=E4=B8=8A=E7=9A=84=E5=80=BC=E4=B8=8D=E6=AD=A3=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor:将持久化回来的列宽给到_visibleColumnsCache,以避免style运算出来的值会以默认列宽或200的数值参与运算 --- src/BootstrapBlazor/Components/Table/Table.razor.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 65cce099d97..0ec7891d254 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1435,7 +1435,10 @@ private void ResetTableColumns() { var item = _tableColumnStates[index]; var col = columnMap[item.Name]; - col.Fixed = !string.IsNullOrEmpty(item.Name) && stateMap.TryGetValue(item.Name, out var state) ? stateMap[item.Name].Fixed : false; + col.Fixed = !string.IsNullOrEmpty(item.Name) && stateMap.TryGetValue(item.Name, out var stateF) ? stateMap[item.Name].Fixed : false; + // 这里一定要使用 stateMap[item.Name].Width 而不是 item.Width 因为 item.Width 可能是 null; + // 以解决持久化回来时,列宽为_visibleColumnsCache里面列宽为null 的问题导致固定列上style时值不正确的问题 + col.Width = !string.IsNullOrEmpty(item.Name) && stateMap.TryGetValue(item.Name, out var stateW) ? stateMap[item.Name].Width: col.Width; if (item.Visible) { // 增加到可见列缓存集合 From 2dd7992506ba5d6e57f75445dfb9ccbdb13d3720 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 12 Jun 2026 20:26:45 +0800 Subject: [PATCH 07/44] doc(Localization): add SearchFormLocalizerOptions localization (#8112) --- localization/de.json | 10 +++++++++- localization/es.json | 10 +++++++++- localization/it.json | 10 +++++++++- localization/km-KH.json | 12 ++++++++++-- localization/pt-BR.json | 10 +++++++++- localization/pt.json | 10 +++++++++- localization/ru-RU.json | 10 +++++++++- localization/th-TH.json | 10 +++++++++- localization/uk-UA.json | 10 +++++++++- localization/zh-TW.json | 8 ++++++++ 10 files changed, 90 insertions(+), 10 deletions(-) diff --git a/localization/de.json b/localization/de.json index bf6cd28c639..6c2c77dddaf 100644 --- a/localization/de.json +++ b/localization/de.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} ist erforderlich." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Netzwerk", + "Title": "Netzwerk", "NetworkType": "Netzwerktyp", "Downlink": "Download", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "Sind Sie sicher, dass Sie die aktuellen Daten löschen möchten?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Alle", + "BooleanAllText": "Alle", + "BooleanTrueText": "Ja", + "BooleanFalseText": "Nein", + "NumberStartValueLabelText": "Startwert", + "NumberEndValueLabelText": "Endwert" } } diff --git a/localization/es.json b/localization/es.json index fc28ecadcba..47fbc140ca9 100644 --- a/localization/es.json +++ b/localization/es.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} es requerido." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Red", + "Title": "Red", "NetworkType": "Tipo de red", "Downlink": "Descarga", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "¿Está seguro de que desea eliminar los datos actuales?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Todos", + "BooleanAllText": "Todos", + "BooleanTrueText": "Sí", + "BooleanFalseText": "No", + "NumberStartValueLabelText": "Inicio", + "NumberEndValueLabelText": "Fin" } } diff --git a/localization/it.json b/localization/it.json index b7490284c34..c94983047d9 100644 --- a/localization/it.json +++ b/localization/it.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} è obbligatorio." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Rete", + "Title": "Rete", "NetworkType": "Tipo di rete", "Downlink": "Download", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "Sei sicuro di voler eliminare i dati correnti?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Tutti", + "BooleanAllText": "Tutti", + "BooleanTrueText": "Sì", + "BooleanFalseText": "No", + "NumberStartValueLabelText": "Inizio", + "NumberEndValueLabelText": "Fine" } } diff --git a/localization/km-KH.json b/localization/km-KH.json index 03e661f4cf4..92c92e067d2 100644 --- a/localization/km-KH.json +++ b/localization/km-KH.json @@ -99,7 +99,7 @@ "BootstrapBlazor.Components.EqualToValidator": { "ErrorMessage": "សូម​បញ្ចូល​តម្លៃ​ដូច​គ្នា​ម្តង​ទៀត" }, - "BootstrapBlazor.Components.ErrorLogger": { + "BootstrapBlazor.Components.BootstrapBlazorErrorBoundary": { "ToastTitle": "កំហុស​កម្មវិធី" }, "BootstrapBlazor.Components.GoTop": { @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} គឺ​ចាំបាច់។" }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "បណ្តាញ", + "Title": "បណ្តាញ", "NetworkType": "ប្រភេទ​បណ្តាញ", "Downlink": "Downlink", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "តើ​អ្នក​ប្រាកដ​ទេ​ថា​ចង់​លុប​ទិន្នន័យ​បច្ចុប្បន្ន?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "ទាំងអស់", + "BooleanAllText": "ទាំងអស់", + "BooleanTrueText": "ពិត", + "BooleanFalseText": "មិនពិត", + "NumberStartValueLabelText": "ចាប់ផ្តើម", + "NumberEndValueLabelText": "បញ្ចប់" } } diff --git a/localization/pt-BR.json b/localization/pt-BR.json index ed84eaa1924..1691acc82c6 100644 --- a/localization/pt-BR.json +++ b/localization/pt-BR.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} é obrigatório." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Rede", + "Title": "Rede", "NetworkType": "Tipo de rede", "Downlink": "Downlink", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "Tem certeza de que deseja excluir os dados atuais?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Todos", + "BooleanAllText": "Todos", + "BooleanTrueText": "Sim", + "BooleanFalseText": "Não", + "NumberStartValueLabelText": "Início", + "NumberEndValueLabelText": "Fim" } } diff --git a/localization/pt.json b/localization/pt.json index 3f1e6fa3ab2..f9deaab3207 100644 --- a/localization/pt.json +++ b/localization/pt.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} é obrigatório." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Rede", + "Title": "Rede", "NetworkType": "Tipo de rede", "Downlink": "Downlink", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "Tem certeza de que deseja excluir os dados atuais?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Todos", + "BooleanAllText": "Todos", + "BooleanTrueText": "Sim", + "BooleanFalseText": "Não", + "NumberStartValueLabelText": "Início", + "NumberEndValueLabelText": "Fim" } } diff --git a/localization/ru-RU.json b/localization/ru-RU.json index 661d4b10566..c84135954c6 100644 --- a/localization/ru-RU.json +++ b/localization/ru-RU.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} обязательно для заполнения." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Сеть", + "Title": "Сеть", "NetworkType": "Тип сети", "Downlink": "Скорость загрузки", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "Вы уверены, что хотите удалить текущие данные?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Все", + "BooleanAllText": "Все", + "BooleanTrueText": "Да", + "BooleanFalseText": "Нет", + "NumberStartValueLabelText": "Начало", + "NumberEndValueLabelText": "Конец" } } diff --git a/localization/th-TH.json b/localization/th-TH.json index a6185f5826f..8492e394c2a 100644 --- a/localization/th-TH.json +++ b/localization/th-TH.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} จำเป็นต้องกรอก" }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "เครือข่าย", + "Title": "เครือข่าย", "NetworkType": "ประเภทเครือข่าย", "Downlink": "ดาวน์โหลด", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "คุณแน่ใจหรือไม่ว่าต้องการลบข้อมูลปัจจุบัน?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "ทั้งหมด", + "BooleanAllText": "ทั้งหมด", + "BooleanTrueText": "ใช่", + "BooleanFalseText": "ไม่ใช่", + "NumberStartValueLabelText": "เริ่มต้น", + "NumberEndValueLabelText": "สิ้นสุด" } } diff --git a/localization/uk-UA.json b/localization/uk-UA.json index 83d2c200d08..25d2f8488ce 100644 --- a/localization/uk-UA.json +++ b/localization/uk-UA.json @@ -397,7 +397,7 @@ "DefaultRequiredErrorMessage": "{0} є обов'язковим." }, "BootstrapBlazor.Components.NetworkMonitorIndicator": { - "NTitle": "Мережа", + "Title": "Мережа", "NetworkType": "Тип мережі", "Downlink": "Швидкість завантаження", "RTT": "RTT" @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "Ви впевнені, що хочете видалити поточні дані?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "Усі", + "BooleanAllText": "Усі", + "BooleanTrueText": "Так", + "BooleanFalseText": "Ні", + "NumberStartValueLabelText": "Початок", + "NumberEndValueLabelText": "Кінець" } } diff --git a/localization/zh-TW.json b/localization/zh-TW.json index 44f25ae7384..2473b581046 100644 --- a/localization/zh-TW.json +++ b/localization/zh-TW.json @@ -407,5 +407,13 @@ }, "BootstrapBlazor.Components.CardUpload": { "DeleteConfirmContent": "您確定要刪除當前資料嗎?" + }, + "BootstrapBlazor.Components.SearchFormLocalizerOptions": { + "SelectAllText": "全部", + "BooleanAllText": "全部", + "BooleanTrueText": "是", + "BooleanFalseText": "否", + "NumberStartValueLabelText": "開始數量", + "NumberEndValueLabelText": "截止數量" } } From b0997d6ff2a365d777a8d9f487b4d3f39a235743 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 13 Jun 2026 08:18:36 +0800 Subject: [PATCH 08/44] fix(Table): use auto table layout when AllowResizing is enabled (#8113) * fix(Table): use auto table layout when AllowResizing is enabled (#8095) Revert table-layout-fixed condition to IsFixedHeader only so that tables with AllowResizing keep content-based initial column widths as before v10.6. Co-Authored-By: Claude Fable 5 * chore(Version): bump version to 10.7.2-beta03 Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/BootstrapBlazor/BootstrapBlazor.csproj | 2 +- src/BootstrapBlazor/Components/Table/Table.razor.cs | 2 +- test/UnitTest/Components/TableTest.cs | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 7614800c8cc..ae7fefb6236 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 10.7.2-beta02 + 10.7.2-beta03 diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 0ec7891d254..71df5dbe23e 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -90,7 +90,7 @@ public partial class Table : ITable, IModelEqualityComparer where .AddClass("table-excel", IsExcel) .AddClass("table-bordered", IsBordered) .AddClass("table-striped table-hover", IsStriped) - .AddClass("table-layout-fixed", IsFixedHeader || AllowResizing) + .AddClass("table-layout-fixed", IsFixedHeader) .AddClass("table-draggable", AllowDragColumn) .Build(); diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index e88e00e0eaf..b835c5361b8 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -1609,6 +1609,7 @@ public void IsFixedHeader_Ok() }); cut.Contains("table-fixed-header"); cut.Contains("height: 200px;"); + cut.Contains("table-layout-fixed"); } [Theory] @@ -1745,6 +1746,9 @@ public void AllowResizing_Ok(bool resizing) { cut.DoesNotContain("is-resizable"); } + + // 开启列宽调整时仍使用 auto 布局保持初始列宽与未开启时一致 + cut.DoesNotContain("table-layout-fixed"); } [Theory] From 781102a24fc5ab5a584fa25e2959f544e5d25651 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sun, 14 Jun 2026 09:21:20 +0800 Subject: [PATCH 09/44] feat(Utility): improve addLink method (#8116) --- src/BootstrapBlazor/wwwroot/modules/utility.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/BootstrapBlazor/wwwroot/modules/utility.js b/src/BootstrapBlazor/wwwroot/modules/utility.js index acbc216d330..7e8d4dd17b4 100644 --- a/src/BootstrapBlazor/wwwroot/modules/utility.js +++ b/src/BootstrapBlazor/wwwroot/modules/utility.js @@ -187,14 +187,22 @@ const addScript = content => { script.setAttribute('src', content) document.body.appendChild(script) script.onload = () => { - script.setAttribute('loaded', true) + script.setAttribute('loaded', 'true') + } + script.onerror = () => { + script.setAttribute('loaded', 'error') } } - return new Promise((resolve, reject) => { + return new Promise(resolve => { const handler = setInterval(() => { - const done = link[0].getAttribute('loaded') === 'true' - if (done) { + const state = link[0].getAttribute('loaded') + if (state === 'true') { + clearInterval(handler) + resolve() + } + else if (state === 'error') { clearInterval(handler) + console.error(`Failed to load script: ${content}`) resolve() } }, 20) From d651f63284b6897a0f9860e93be890eb71fb76c0 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 15 Jun 2026 09:39:08 +0800 Subject: [PATCH 10/44] feat(Utility): improve addLink method (#8118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(Utility): improve addLink method * chore: bump version to 10.7.2-beta04 Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: 增加超时策略逻辑 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/BootstrapBlazor/BootstrapBlazor.csproj | 2 +- .../wwwroot/modules/utility.js | 33 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index ae7fefb6236..cf3174b47e5 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 10.7.2-beta03 + 10.7.2-beta04 diff --git a/src/BootstrapBlazor/wwwroot/modules/utility.js b/src/BootstrapBlazor/wwwroot/modules/utility.js index 7e8d4dd17b4..8f6da49166d 100644 --- a/src/BootstrapBlazor/wwwroot/modules/utility.js +++ b/src/BootstrapBlazor/wwwroot/modules/utility.js @@ -181,7 +181,9 @@ const addScript = content => { let link = scripts.filter(function(link) { return link.src.indexOf(url) > -1 }) + let created = false if (link.length === 0) { + created = true const script = document.createElement('script') link.push(script) script.setAttribute('src', content) @@ -194,6 +196,7 @@ const addScript = content => { } } return new Promise(resolve => { + let count = 0 const handler = setInterval(() => { const state = link[0].getAttribute('loaded') if (state === 'true') { @@ -205,6 +208,11 @@ const addScript = content => { console.error(`Failed to load script: ${content}`) resolve() } + else if (!created && ++count > 250) { + clearInterval(handler) + console.warn(`Timeout while loading script: ${content}`) + resolve() + } }, 20) }) } @@ -246,21 +254,38 @@ const addLink = (href, rel = "stylesheet") => { let link = links.filter(function(link) { return link.href.indexOf(url) > -1 }) + let created = false if (link.length === 0) { + created = true const css = document.createElement('link') link.push(css) css.setAttribute("rel", rel) css.setAttribute('href', href) document.getElementsByTagName("head")[0].appendChild(css) css.onload = () => { - css.setAttribute('loaded', true) + css.setAttribute('loaded', 'true') + } + css.onerror = () => { + css.setAttribute('loaded', 'error') } } - return new Promise((resolve, reject) => { + return new Promise(resolve => { + let count = 0 const handler = setInterval(() => { - const done = link[0].getAttribute('loaded') === 'true' - if (done) { + const el = link[0] + const state = el.getAttribute('loaded') + if (state === 'true' || el.sheet) { + clearInterval(handler) + resolve() + } + else if (state === 'error') { + clearInterval(handler) + console.error(`Failed to load stylesheet: ${href}`) + resolve() + } + else if (!created && ++count > 250) { clearInterval(handler) + console.warn(`Timeout while loading stylesheet: ${href}`) resolve() } }, 20) From ee73d095181834a00bfb32a4f158d44e855b0543 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 15 Jun 2026 13:55:49 +0800 Subject: [PATCH 11/44] feat(DockView): bump version 10.0.15 (#8120) --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index a762b99012c..0118f30c752 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -34,7 +34,7 @@ - + From 5ed381eda371bc353175305a02b4cc04cf7c1227 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 16 Jun 2026 11:33:45 +0800 Subject: [PATCH 12/44] feat(DockView): bump version 10.0.16 (#8122) --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index 0118f30c752..b1e3f7a5d0b 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -34,7 +34,7 @@ - + From 892e07093ba346e14499130826fd4a342525e5d4 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 16 Jun 2026 11:38:34 +0800 Subject: [PATCH 13/44] feat(Html2Pdf): bump version 10.0.9 (#8124) --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index b1e3f7a5d0b..5e806b072e7 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -46,7 +46,7 @@ - + From f172166c8ef121ce6690836216ad96eab8b935a8 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 17 Jun 2026 01:40:11 +0800 Subject: [PATCH 14/44] feat(Table): add auto width function (#8126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 增加客户端未设置列宽列自适应功能 * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Argo Zhang * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Argo Zhang * chore: bump version 10.7.2 --------- Signed-off-by: Argo Zhang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/BootstrapBlazor/BootstrapBlazor.csproj | 2 +- .../Components/Table/Table.razor.Edit.cs | 6 ++ .../Components/Table/Table.razor.cs | 5 + .../Components/Table/Table.razor.js | 96 +++++++++++++++++-- 4 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index cf3174b47e5..78417ff06e1 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 10.7.2-beta04 + 10.7.2 diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Edit.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Edit.cs index 33a9ee11886..6d916cfa45d 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Edit.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Edit.cs @@ -516,6 +516,12 @@ private async Task QueryAsync(bool shouldRender, int? pageIndex = null, bool tri if (shouldRender) { + // 数据渲染后触发脚本测量列内容最小宽度 用于固定表头(table-layout:fixed)窄容器下横向滚动兜底 + if (IsFixedHeader) + { + _measureColumnMinWidth = true; + _invoke = true; + } StateHasChanged(); } } diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 71df5dbe23e..a7e6d6dfd04 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -522,6 +522,7 @@ private string GetSortTooltip(ITableColumn col) => SortName != col.GetFieldName( private bool _resetTable; private bool _resetColumnListPopover; private bool _resetColumns; + private bool _measureColumnMinWidth; private bool _updateSortTooltip; private bool _shouldScrollTop; private bool _invoke; @@ -1151,6 +1152,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) var resetColumnListPopover = _resetColumnListPopover; var resetTable = _resetTable; var resetColumns = _resetColumns; + var measureColumnMinWidth = _measureColumnMinWidth; var updateSortTooltip = _updateSortTooltip; var scrollToTop = _shouldScrollTop; @@ -1158,6 +1160,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) _resetColumnListPopover = false; _resetTable = false; _resetColumns = false; + _measureColumnMinWidth = false; _updateSortTooltip = false; _shouldScrollTop = false; @@ -1167,6 +1170,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender) ResetColumnListPopover = resetColumnListPopover, ResetTable = resetTable, ResetColumns = resetColumns, + MeasureColumnMinWidth = measureColumnMinWidth, ColumnStates = _tableColumnStates, AllowDragColumn, UpdateSortTooltip = updateSortTooltip, @@ -1468,6 +1472,7 @@ private async Task OnTableRenderAsync(bool firstRender) FitColumnWidthIncludeHeader, ResizeColumnCallback = nameof(ResizeColumnCallback), ColumnMinWidth = ColumnMinWidth ?? Options.CurrentValue.TableSettings.ColumnMinWidth, + ColumnStates = _tableColumnStates, ScrollWidth = ActualScrollWidth, ShowColumnWidthTooltip, ColumnWidthTooltipPrefix, diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 0de8d1c639b..93d0c87c25c 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -26,6 +26,16 @@ export async function reset(id) { return; } + if (table.minWidthObserver) { + table.minWidthObserver.disconnect(); + table.minWidthObserver = null; + } + if (table.minWidthRaf) { + cancelAnimationFrame(table.minWidthRaf); + table.minWidthRaf = null; + } + table.autoColumns = []; + table.columns = []; table.tables = []; table.dragColumns = []; @@ -51,6 +61,19 @@ export async function reset(id) { const left = table.body.scrollLeft table.thead.scrollTo(left, 0) }); + + if (window.ResizeObserver) { + table.minWidthObserver = new ResizeObserver(() => { + if (table.minWidthRaf) { + cancelAnimationFrame(table.minWidthRaf); + } + table.minWidthRaf = requestAnimationFrame(() => { + table.minWidthRaf = null; + applyColumnMinWidth(table); + }); + }); + table.minWidthObserver.observe(table.body); + } } else { table.isExcel = shim.firstElementChild.classList.contains('table-excel') @@ -91,6 +114,10 @@ export async function reset(id) { table.loopCheckHeightHandler = requestAnimationFrame(() => check(table)); return; } + + if (table.thead) { + setColSize(table, table.options); + } } export async function switchCardView(id) { @@ -113,6 +140,13 @@ const destroyTable = table => { if (table.loopCheckHeightHandler) { cancelAnimationFrame(table.loopCheckHeightHandler); } + if (table.minWidthObserver) { + table.minWidthObserver.disconnect(); + table.minWidthObserver = null; + } + if (table.minWidthRaf) { + cancelAnimationFrame(table.minWidthRaf); + } if (table.thead) { EventHandler.off(table.body, 'scroll') } @@ -1075,6 +1109,9 @@ export async function updateTableState(id, options) { if (options.resetColumns) { resetColumns(table, options); } + else if (options.measureColumnMinWidth) { + setColSize(table, options); + } if (options.resetColumnListPopover) { resetColumnListPopover(table); @@ -1134,18 +1171,57 @@ const resetColumns = (table, options) => { } const setColSize = (table, options) => { - var zeroWidthColumns = options.columnStates.filter(i => i.width === null && i.visible === true); - zeroWidthColumns.forEach(col => { - const headerCollection = [...table.tables[0].querySelectorAll('thead > tr > th')]; + if (!table.tables || table.tables.length === 0) { + return; + } + const headerCollection = [...table.tables[0].querySelectorAll('thead > tr > th')]; + const columnMinWidth = table.options.columnMinWidth || 0; + const autoColumns = []; + options.columnStates.forEach(col => { + if (col.width != null || col.visible === false) { + return; + } const th = headerCollection.find(i => i.getAttribute('data-bb-field') === col.name); - if (th.offsetWidth === 0) { - const width = getCellWidth(th); - const colIndex = headerCollection.indexOf(th); - col.width = Math.max(width, getColumnMaxCellWidth(table, colIndex)); - table.tables.forEach(table => { - table.querySelectorAll('colgroup col')[colIndex].style.width = `${col.width}px`; - }); + if (th === void 0) { + return; } + const colIndex = headerCollection.indexOf(th); + const minWidth = Math.max(getCellWidth(th) + getHeaderIconsWidth(th), getColumnMaxCellWidth(table, colIndex), columnMinWidth) | 0; + autoColumns.push({ colIndex, minWidth }); + }); + table.autoColumns = autoColumns; + applyColumnMinWidth(table); +} + +const getHeaderIconsWidth = th => { + let width = 0; + th.querySelectorAll('.sort-icon, .filter-icon, .toolbox-icon, .col-copy').forEach(icon => { + width += getWidth(icon); + }); + return width; +} + +const applyColumnMinWidth = table => { + if (!table.thead || !table.body || !table.autoColumns || table.autoColumns.length === 0) { + return; + } + setAutoColWidths(table, true); + const compact = table.body.scrollWidth > table.body.clientWidth + 1; + if (!compact) { + setAutoColWidths(table, false); + } +} + +const setAutoColWidths = (table, apply) => { + const colgroups = table.tables.map(t => t.querySelectorAll('colgroup col')); + table.autoColumns.forEach(({ colIndex, minWidth }) => { + const width = apply ? `${minWidth}px` : ''; + colgroups.forEach(cols => { + const colEl = cols[colIndex]; + if (colEl) { + colEl.style.width = width; + } + }); }); } From db8e375b3ef0a0c8755e83ed8a79413f8e8f5bd4 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 17 Jun 2026 19:36:42 +0800 Subject: [PATCH 15/44] feat(DockView): bump version 10.0.17 (#8128) --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index 5e806b072e7..4adfad808fc 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -34,7 +34,7 @@ - + From 67f07be019b144d98f1782b0069c4e6c2e03cd99 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 18 Jun 2026 21:20:55 +0800 Subject: [PATCH 16/44] chore(LLMs): remove LLMs auto docs (#8130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 移除 llms 工具 * chore: 删除 llms 相关脚本 * refactor: 重构自动化脚本 --- dotnet-tools.json | 7 ------ .../Directory.Build.targets | 22 +++++++------------ 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/dotnet-tools.json b/dotnet-tools.json index e56323b0952..8cc47019440 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -8,13 +8,6 @@ "css-bundler" ], "rollForward": false - }, - "bootstrapblazor.llmsdocsgenerator": { - "version": "10.0.2", - "commands": [ - "llms-docs" - ], - "rollForward": false } } } \ No newline at end of file diff --git a/src/BootstrapBlazor.Server/Directory.Build.targets b/src/BootstrapBlazor.Server/Directory.Build.targets index 124d0433260..38faf0eb713 100644 --- a/src/BootstrapBlazor.Server/Directory.Build.targets +++ b/src/BootstrapBlazor.Server/Directory.Build.targets @@ -1,25 +1,19 @@ - - - - - - - + - - - - - - + - + + + + + + From 4c525aefa89ac8a0d692326bdbccfd430b1a7980 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 18 Jun 2026 21:32:07 +0800 Subject: [PATCH 17/44] feat(DockView): bump version 10.0.18 (#8132) --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index 4adfad808fc..2402bf14b76 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -34,7 +34,7 @@ - + From d811fa9f432838f3861385cb98f8f23bf856357e Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Fri, 19 Jun 2026 09:43:32 +0800 Subject: [PATCH 18/44] feat(UniverSheet): bump version 10.0.14 (#8134) --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index 2402bf14b76..c56c95f7ded 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -81,7 +81,7 @@ - + From fc2fa3da7b208e6eb0fe7bfb50c0f9f3e4145f0a Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 20 Jun 2026 18:08:34 +0800 Subject: [PATCH 19/44] chore(LLMs): add LLMsDocsGenerator tool (#8136) --- dotnet-tools.json | 7 +++++++ src/BootstrapBlazor.Server/Directory.Build.targets | 10 ++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/dotnet-tools.json b/dotnet-tools.json index 8cc47019440..e56323b0952 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -8,6 +8,13 @@ "css-bundler" ], "rollForward": false + }, + "bootstrapblazor.llmsdocsgenerator": { + "version": "10.0.2", + "commands": [ + "llms-docs" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/src/BootstrapBlazor.Server/Directory.Build.targets b/src/BootstrapBlazor.Server/Directory.Build.targets index 38faf0eb713..722564f0ce9 100644 --- a/src/BootstrapBlazor.Server/Directory.Build.targets +++ b/src/BootstrapBlazor.Server/Directory.Build.targets @@ -11,8 +11,14 @@ - - + + + + + + + + From 3aeb67c81a2e6829c1d0a54498ddcb237ea70fa4 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sun, 21 Jun 2026 15:05:55 +0800 Subject: [PATCH 20/44] doc(NavMenu): update component category (#8138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * doc: 调整键值与组件名称一致 * refactor: 更改图标键值 * doc: 更新标签键值 --- .../Extensions/MenusLocalizerExtensions.cs | 14 +++++++------- src/BootstrapBlazor.Server/Locales/en-US.json | 14 +++++++------- src/BootstrapBlazor.Server/Locales/zh-CN.json | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs index c4c95aceafa..3c0d497349d 100644 --- a/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs +++ b/src/BootstrapBlazor.Server/Extensions/MenusLocalizerExtensions.cs @@ -321,7 +321,7 @@ void AddQuickStar(DemoMenuItem item) }, new() { - Text = Localizer["Labels"], + Text = Localizer["BootstrapLabel"], Url = "label" }, new() @@ -437,22 +437,22 @@ void AddForm(DemoMenuItem item) new() { Match = NavLinkMatch.All, - Text = Localizer["Input"], + Text = Localizer["BootstrapInput"], Url = "input" }, new() { - Text = Localizer["InputNumber"], + Text = Localizer["BootstrapInputNumber"], Url = "input-number" }, new() { - Text = Localizer["InputGroup"], + Text = Localizer["BootstrapInputGroup"], Url = "input-group" }, new() { - Text = Localizer["Ip"], + Text = Localizer["IpAddress"], Url = "ip" }, new() @@ -751,7 +751,7 @@ void AddData(DemoMenuItem item) }, new() { - Text = Localizer["Icon"], + Text = Localizer["BootstrapBlazorIcon"], Url = "icon" }, new() @@ -1265,7 +1265,7 @@ void AddNotice(DemoMenuItem item) }, new() { - Text = Localizer["Console"], + Text = Localizer["ConsoleLogger"], Url = "console" }, new() diff --git a/src/BootstrapBlazor.Server/Locales/en-US.json b/src/BootstrapBlazor.Server/Locales/en-US.json index b09fbe5fc01..23597e1d4b5 100644 --- a/src/BootstrapBlazor.Server/Locales/en-US.json +++ b/src/BootstrapBlazor.Server/Locales/en-US.json @@ -200,7 +200,7 @@ "Collapse": "Collapse", "ColorPicker": "ColorPicker", "ConnectionService": "ConnectionService", - "Console": "Console", + "ConsoleLogger": "ConsoleLogger", "ContextMenu": "ContextMenu", "CountButton": "CountButton", "CountUp": "CountUp", @@ -278,23 +278,23 @@ "Html2Image": "IHtml2Image", "Html2Pdf": "IHtml2Pdf", "HtmlRenderer": "HtmlRenderer", - "Icon": "Icon", + "BootstrapBlazorIcon": "Icon", "IconPark": "ByteDance IconPark", "Icons": "Icons", "IFrame": "IFrame", "ImageCropper": "ImageCropper", "ImageViewer": "ImageViewer", - "Input": "Input", - "InputGroup": "InputGroup", - "InputNumber": "InputNumber", + "BootstrapInput": "BootstrapInput", + "BootstrapInputGroup": "BootstrapInputGroup", + "BootstrapInputNumber": "BootstrapInputNumber", "InputUpload": "InputUpload", "Install": "Install", "IntersectionObserver": "IntersectionObserver", "Introduction": "Introduction", - "Ip": "IpAddress", + "IpAddress": "IpAddress", "JitViewer": "Jit Viewer", "JSExtension": "JSRuntime Extensions", - "Labels": "Labels", + "BootstrapLabel": "Labels", "Layout": "Layout", "LayoutComponents": "Layouts", "LayoutPage": "Admin Template", diff --git a/src/BootstrapBlazor.Server/Locales/zh-CN.json b/src/BootstrapBlazor.Server/Locales/zh-CN.json index a73b1138679..7450034e10f 100644 --- a/src/BootstrapBlazor.Server/Locales/zh-CN.json +++ b/src/BootstrapBlazor.Server/Locales/zh-CN.json @@ -200,7 +200,7 @@ "Collapse": "折叠 Collapse", "ColorPicker": "颜色拾取器 ColorPicker", "ConnectionService": "在线连接服务 ConnectionService", - "Console": "控制台 Console", + "ConsoleLogger": "控制台 ConsoleLogger", "ContextMenu": "右键菜单 ContextMenu", "CountButton": "倒计时按钮 CountButton", "CountUp": "计数器 CountUp", @@ -278,23 +278,23 @@ "Html2Image": "Html 转 Image IHtml2Image", "Html2Pdf": "Html 转 Pdf IHtml2Pdf", "HtmlRenderer": "Html 转换器 HtmlRenderer", - "Icon": "图标 Icon", + "BootstrapBlazorIcon": "图标 Icon", "IconPark": "字节跳动图标 IconPark", "Icons": "内置图标", "IFrame": "内嵌框架 IFrame", "ImageCropper": "图像裁剪 ImageCropper", "ImageViewer": "图片 ImageViewer", - "Input": "输入框 Input", - "InputGroup": "输入组 InputGroup", - "InputNumber": "数字框 InputNumber", + "BootstrapInput": "输入框 BootstrapInput", + "BootstrapInputGroup": "输入组 BootstrapInputGroup", + "BootstrapInputNumber": "数字框 BootstrapInputNumber", "InputUpload": "上传组件 InputUpload", "Install": "安装", "IntersectionObserver": "交叉观察者 IntersectionObserver", "Introduction": "简介", - "Ip": "IP 地址 IpAddress", + "IpAddress": "IP 地址 IpAddress", "JitViewer": "文件预览器 JitViewer", "JSExtension": "JSRuntime 扩展", - "Labels": "表单标签", + "BootstrapLabel": "表单标签", "Layout": "布局组件 Layout", "LayoutComponents": "布局组件", "LayoutPage": "后台模拟器", From 9f70b5f60f745be14e206d8d1061d0b4e5eb501f Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sun, 21 Jun 2026 15:07:00 +0800 Subject: [PATCH 21/44] chore(Docs): update docs.json configuration file (#8140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * doc: 调整键值与组件名称一致 * refactor: 更改图标键值 * doc: 更新标签键值 * doc: 更新分类设置 * doc: 更新排序 * doc: 增加服务列表 * doc: 更新组件分类 --- src/BootstrapBlazor.Server/docs.json | 287 ++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 1568c92d9db..79ce1614109 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -334,5 +334,290 @@ "AtomUI": "https://qinware.com/?from=blazor.zone", "SAPHP": "https://www.swiftadmin.net/?from=blazor.zone", "Veterinary Hospital": "http://animal.jucun.zone/?from=blazor.zone" - } + }, + "category": { + "基类": [ + "BootstrapComponentBase", + "BootstrapModuleComponentBase", + "IdComponentBase" + ], + "工具类组件": [ + "ReconnectorContent", + "RenderTemplate", + "Script", + "ThemeProvider" + ], + "布局组件": [ + "BootstrapBlazorRoot", + "Divider", + "Footer", + "Layout", + "LayoutSplitBar", + "Navbar", + "Row", + "Scroll", + "Split", + "Stack", + "Toolbar", + "ToolbarButtonGroup", + "ToolbarItem", + "ToolbarSeparator", + "ToolbarSpace" + ], + "导航组件": [ + "Anchor", + "AnchorLink", + "Breadcrumb", + "Dropdown", + "DropdownItem", + "GotoNavigator", + "GoTop", + "Logout", + "Menu", + "MenuLink", + "Nav", + "NavbarBrand", + "NavbarCollapse", + "NavbarDropdown", + "NavbarDropdownItem", + "NavbarGroup", + "NavbarItem", + "NavbarLink", + "NavbarToggleButton", + "Pagination", + "PaginationItem", + "RibbonTab", + "RibbonTabHeader", + "SideMenu", + "Step", + "SubCascader", + "SubMenu", + "Tab", + "TabLink", + "TabToolbarButton", + "TopMenu" + ], + "表单组件": [ + "AutoComplete", + "AutoFill", + "BootstrapInput", + "BootstrapInputBase", + "BootstrapInputEventBase", + "BootstrapInputGroup", + "BootstrapInputGroupIcon", + "BootstrapInputNumber", + "BootstrapInputNumberBase", + "Button", + "ButtonBase", + "Cascader", + "Checkbox", + "CheckboxList", + "CheckboxListGeneric", + "ClockPicker", + "ColorPicker", + "DatePickerBody", + "DatePickerCell", + "DateTimePicker", + "DateTimeRange", + "EditorForm", + "FloatingLabel", + "IpAddress", + "ListGroup", + "MultiSelect", + "MultiSelectGeneric", + "NullSwitch", + "OtpInput", + "PopoverCompleteBase", + "PopoverDropdownBase", + "PopoverSelectBase", + "PulseButton", + "Radio", + "RadioList", + "RadioListGeneric", + "Rate", + "SearchForm", + "Select", + "SelectBase", + "SelectGeneric", + "SelectObject", + "SelectTable", + "SelectTree", + "SimpleSelectBase", + "Slider", + "Switch", + "Textarea", + "TimePicker", + "TimePickerCell", + "Toggle", + "ToggleBase", + "ToggleButton", + "TooltipWrapperBase", + "Transfer", + "TransferPanel", + "ValidateBase", + "ValidateForm" + ], + "上传组件": [ + "AvatarUpload", + "ButtonUpload", + "CardUpload", + "DropUpload", + "FileListUploadBase", + "InputUpload", + "UploadBase", + "UploadPreviewList" + ], + "表格组件": [ + "Table", + "TableAdvancedSortDialog", + "TableExtensionButton", + "TableFooterCell", + "TableToolbar" + ], + "数据组件": [ + "Affix", + "Avatar", + "Badge", + "BootstrapBlazorIcon", + "BootstrapLabel", + "BootstrapLabelSetting", + "Calendar", + "Camera", + "Captcha", + "Card", + "Carousel", + "Circle", + "CircleBase", + "Collapse", + "CountUp", + "Display", + "DisplayBase", + "DropdownWidget", + "Empty", + "FileIcon", + "GroupBox", + "Handwritten", + "IFrame", + "ImagePreviewer", + "ImageViewer", + "Link", + "ListView", + "LoadMore", + "LogoutLink", + "PrintButton", + "QueryBuilder", + "QueryGroup", + "Repeater", + "Search", + "Segmented", + "ShieldBadge", + "SpeechWave", + "SvgIcon", + "SwitchButton", + "Tag", + "Timeline", + "Transition", + "Tree", + "TreeView", + "TreeViewRow", + "TreeViewToolbarEditButton", + "Typed", + "Waterfall", + "Watermark" + ], + "通知组件": [ + "Alert", + "AlertBase", + "Console", + "ConsoleLogger", + "ContextMenu", + "ContextMenuZone", + "DialButton", + "Dialog", + "DialogBase", + "Drawer", + "Dropzone", + "EditDialog", + "FlipClock", + "FullScreenButton", + "IconDialog", + "IntersectionObserver", + "IntersectionObserverItem", + "Light", + "Marquee", + "Mask", + "Message", + "Modal", + "ModalDialog", + "NetworkMonitorIndicator", + "PopConfirmButton", + "PopConfirmButtonBase", + "PopConfirmButtonContent", + "Popover", + "Progress", + "ResultDialogFooter", + "SearchDialog", + "SlideButton", + "Spinner", + "SweetAlert", + "SweetAlertBody", + "Timer", + "Toast", + "ToastContainer", + "Tooltip" + ], + "过滤器组件": [ + "BoolFilter", + "DateTimeFilter", + "EnumFilter", + "FilterBase", + "FilterButton", + "FilterLogicItem", + "FilterProvider", + "LookupFilter", + "MultiFilter", + "MultipleFilterBase", + "MultiSelectFilter", + "NotSupportFilter", + "NumberFilter", + "StringFilter", + "TableColumnFilter" + ], + "骨架屏组件": [ + "SkeletonAvatar", + "SkeletonBase", + "SkeletonTable", + "SkeletonTree" + ] + }, + "services": [ + "AjaxService", + "ClipboardService", + "DialogService", + "DownloadService", + "DrawerService", + "EyeDropperService", + "FullScreenService", + "IBrowserFingerService", + "IConnectionService", + "IGeoLocationService", + "IHtml2Image", + "IHtml2Pdf", + "ILookupService", + "INetworkMonitorService", + "IPinyinService", + "ISerialService", + "ITotpService", + "IVersionService", + "IZipArchiveService", + "MaskService", + "MessageService", + "NotificationService", + "PrintService", + "SwalService", + "TitleService", + "ToastService", + "WebClientService", + "WebSpeechService" + ] } From 10991e1a795e58592c01f4d606fe9869ad4c8767 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 22 Jun 2026 09:19:45 +0800 Subject: [PATCH 22/44] doc(Docs): update extensions components/services (#8142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * doc: 增加扩展组件与服务配置项 * doc: 补扩展服务 --- src/BootstrapBlazor.Server/docs.json | 228 ++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/docs.json b/src/BootstrapBlazor.Server/docs.json index 79ce1614109..c242d552cac 100644 --- a/src/BootstrapBlazor.Server/docs.json +++ b/src/BootstrapBlazor.Server/docs.json @@ -598,17 +598,25 @@ "DrawerService", "EyeDropperService", "FullScreenService", + "IAudioDevice", + "IBluetooth", "IBrowserFingerService", + "ICalendarFestivals", + "ICalendarHolidays", + "IComponentHtmlRenderer", "IConnectionService", "IGeoLocationService", "IHtml2Image", "IHtml2Pdf", + "IIpLocatorProvider", "ILookupService", "INetworkMonitorService", "IPinyinService", "ISerialService", + "ITableExport", "ITotpService", "IVersionService", + "IVideoDevice", "IZipArchiveService", "MaskService", "MessageService", @@ -619,5 +627,223 @@ "ToastService", "WebClientService", "WebSpeechService" - ] + ], + "extensions": { + "BootstrapBlazor.AntDesignIcon": { + "components": ["AntDesignIcon"], + "services": [] + }, + "BootstrapBlazor.AzureOpenAI": { + "components": [], + "services": ["IAzureOpenAIService"] + }, + "BootstrapBlazor.AzureTranslator": { + "components": [], + "services": ["IAzureTranslatorService"] + }, + "BootstrapBlazor.BaiduOcr": { + "components": [], + "services": ["IBaiduOcr"] + }, + "BootstrapBlazor.BarCode": { + "components": ["BarcodeReader", "QRCode"], + "services": [] + }, + "BootstrapBlazor.BarcodeGenerator": { + "components": ["BarcodeGenerator"], + "services": [] + }, + "BootstrapBlazor.Chart": { + "components": ["Chart"], + "services": [] + }, + "BootstrapBlazor.ChatBot": { + "components": ["MarkdownContent"], + "services": [] + }, + "BootstrapBlazor.CherryMarkdown": { + "components": ["CherryMarkdown"], + "services": [] + }, + "BootstrapBlazor.CodeEditor": { + "components": ["CodeEditor"], + "services": [] + }, + "BootstrapBlazor.Dock": { + "components": ["DockComponent", "DockView"], + "services": [] + }, + "BootstrapBlazor.DockView": { + "components": ["DockViewComponent", "DockViewDropdownIcon", "DockViewIcon", "DockViewTitleBar", "DockViewV2"], + "services": [] + }, + "BootstrapBlazor.Dom2Image": { + "components": [], + "services": ["IDom2ImageService"] + }, + "BootstrapBlazor.DriverJs": { + "components": ["DriverJs"], + "services": [] + }, + "BootstrapBlazor.ElementIcon": { + "components": ["ElementIcon"], + "services": [] + }, + "BootstrapBlazor.EmbedPDF": { + "components": ["EmbedPDF"], + "services": [] + }, + "BootstrapBlazor.FileViewer": { + "components": ["FileViewer"], + "services": [] + }, + "BootstrapBlazor.Gantt": { + "components": ["Gantt"], + "services": [] + }, + "BootstrapBlazor.HikVision": { + "components": ["HikVisionWebPlugin"], + "services": [] + }, + "BootstrapBlazor.Html2Image": { + "components": [], + "services": ["IHtml2Image"] + }, + "BootstrapBlazor.Html2Pdf": { + "components": [], + "services": ["IHtml2Pdf"] + }, + "BootstrapBlazor.IconPark": { + "components": ["ByteDanceIcon"], + "services": [] + }, + "BootstrapBlazor.ImageCropper": { + "components": ["ImageCropper"], + "services": [] + }, + "BootstrapBlazor.JitsiMeet": { + "components": ["Meet"], + "services": [] + }, + "BootstrapBlazor.JitViewer": { + "components": ["JitViewer"], + "services": [] + }, + "BootstrapBlazor.Live2DDisplay": { + "components": ["Live2DDisplay"], + "services": [] + }, + "BootstrapBlazor.Markdown": { + "components": ["Markdown"], + "services": [] + }, + "BootstrapBlazor.MeiliSearch": { + "components": ["MeiliSearchBox"], + "services": [] + }, + "BootstrapBlazor.Mermaid": { + "components": ["Mermaid"], + "services": [] + }, + "BootstrapBlazor.MindMap": { + "components": ["MindMap"], + "services": [] + }, + "BootstrapBlazor.MouseFollower": { + "components": ["MouseFollower"], + "services": [] + }, + "BootstrapBlazor.OctIcon": { + "components": ["OctIcon"], + "services": [] + }, + "BootstrapBlazor.OfficeViewer": { + "components": ["OfficeViewer"], + "services": [] + }, + "BootstrapBlazor.OnScreenKeyboard": { + "components": ["OnScreenKeyboard"], + "services": [] + }, + "BootstrapBlazor.OpcDa": { + "components": [], + "services": ["IOpcDaServer", "IOpcItem", "IOpcSubscription"] + }, + "BootstrapBlazor.PdfReader": { + "components": ["PdfReader"], + "services": [] + }, + "BootstrapBlazor.PdfViewer": { + "components": ["PdfViewer"], + "services": [] + }, + "BootstrapBlazor.Player": { + "components": ["Player"], + "services": [] + }, + "BootstrapBlazor.RDKit": { + "components": ["RDKit"], + "services": [] + }, + "BootstrapBlazor.Region": { + "components": ["SelectCity", "SelectProvince", "SelectRegion"], + "services": ["IRegionService"] + }, + "BootstrapBlazor.SignaturePad": { + "components": ["SignaturePad"], + "services": [] + }, + "BootstrapBlazor.SmilesDrawer": { + "components": ["SmilesDrawer"], + "services": [] + }, + "BootstrapBlazor.Sortable": { + "components": ["SortableList"], + "services": [] + }, + "BootstrapBlazor.Splitting": { + "components": ["Splitting"], + "services": [] + }, + "BootstrapBlazor.SummerNote": { + "components": ["Editor"], + "services": [] + }, + "BootstrapBlazor.SvgEditor": { + "components": ["SvgEditor"], + "services": [] + }, + "BootstrapBlazor.Tasks.Dashboard": { + "components": ["TaskDashboard", "TaskInfo"], + "services": [] + }, + "BootstrapBlazor.Term": { + "components": ["Term"], + "services": [] + }, + "BootstrapBlazor.Topology": { + "components": ["Topology"], + "services": [] + }, + "BootstrapBlazor.UniverIcon": { + "components": ["UniverIcon"], + "services": [] + }, + "BootstrapBlazor.UniverSheet": { + "components": ["UniverSheet"], + "services": [] + }, + "BootstrapBlazor.Vditor": { + "components": ["Vditor"], + "services": [] + }, + "BootstrapBlazor.VideoPlayer": { + "components": ["VideoPlayer"], + "services": [] + }, + "BootstrapBlazor.WinBox": { + "components": ["WinBox"], + "services": ["WinBoxService"] + } + } } From f44940cd963351743965b58649a8ced58716ad28 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 22 Jun 2026 09:38:03 +0800 Subject: [PATCH 23/44] chore(LLMs): bump version 10.0.3 (#8144) --- dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet-tools.json b/dotnet-tools.json index e56323b0952..a54cec9c9af 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "bootstrapblazor.llmsdocsgenerator": { - "version": "10.0.2", + "version": "10.0.3", "commands": [ "llms-docs" ], From 9b777c4fb4ac90b3a2a6e304c81a172fa4e57344 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Mon, 22 Jun 2026 19:38:01 +0800 Subject: [PATCH 24/44] fix(AutoComplete): skip filter when set SkipMatch to true (#8149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复 AutoComplete SkipMatch 选中候选项后不生效问题 * test: 更新单元测试 * chore: bump version 10.7.3-beta01 * refactor: 精简代码 * chore: bump version 10.7.3-beta02 * chore: bump version 10.7.3-beta01 --- src/BootstrapBlazor/BootstrapBlazor.csproj | 2 +- .../AutoComplete/AutoComplete.razor.cs | 5 +++++ test/UnitTest/Components/AutoCompleteTest.cs | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 78417ff06e1..3173192f198 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 10.7.2 + 10.7.3-beta01 diff --git a/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.cs b/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.cs index 8b4d1e4c442..14915742646 100644 --- a/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.cs +++ b/src/BootstrapBlazor/Components/AutoComplete/AutoComplete.razor.cs @@ -237,6 +237,11 @@ public async Task TriggerClear() [JSInvokable] public async Task TriggerFilter(string val) { + if (SkipMatch) + { + return; + } + if (OnCustomFilter != null) { var items = await OnCustomFilter(val); diff --git a/test/UnitTest/Components/AutoCompleteTest.cs b/test/UnitTest/Components/AutoCompleteTest.cs index fd38893bba3..2c5c953183a 100644 --- a/test/UnitTest/Components/AutoCompleteTest.cs +++ b/test/UnitTest/Components/AutoCompleteTest.cs @@ -23,6 +23,23 @@ public void SkipMatch_Ok() Assert.Equal(4, items.Count); } + [Fact] + public async Task SkipMatch_AfterSelect_Ok() + { + var cut = Context.Render(pb => + { + pb.Add(a => a.SkipMatch, true); + pb.Add(a => a.Items, new List() { "test1", "test12", "test123", "test1234" }); + }); + + // 选中候选项 test123 后 TriggerFilter 不应过滤候选项 + // 否则再次输入时 SkipMatch 失效 候选项被锁定为上次选中值的过滤子集 + await cut.InvokeAsync(() => cut.FindAll(".dropdown-item")[2].Click()); + + var items = cut.FindAll(".dropdown-item"); + Assert.Equal(4, items.Count); + } + [Fact] public void Items_Ok() { From e2a8ed418b01a4400c22fac0a892a6a18b57f5d3 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 23 Jun 2026 14:35:35 +0800 Subject: [PATCH 25/44] feat(DockView): make IsLock parameter work event set ShowLock to false (#8151) bump version 10.0.19 --- src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj index c56c95f7ded..5ee9e9ff11b 100644 --- a/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj +++ b/src/BootstrapBlazor.Server/BootstrapBlazor.Server.csproj @@ -34,7 +34,7 @@ - + From 2023878d583b28780bc6a3ea22b47c271156e994 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 24 Jun 2026 11:35:57 +0800 Subject: [PATCH 26/44] doc(SvgIcon): update Href parameter doc (#8153) --- src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs b/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs index 4728d9a60b9..1d61cdb98ee 100644 --- a/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs +++ b/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs @@ -20,8 +20,8 @@ public partial class SvgIcon public string? Name { get; set; } /// - /// 获得 图标地址 - /// Get Icon URL + /// 获得/设置 图标地址,默认 null 未设置,内部使用 BootstrapBlazor.IconPark 包中提供的图标 + /// Gets or sets Icon URL, default is null, internal use BootstrapBlazor.IconPark package provided icons /// [Parameter, NotNull] public string? Href { get; set; } From 81d18e0d189a3dff15064fbc63c338a347169596 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 24 Jun 2026 12:41:32 +0800 Subject: [PATCH 27/44] feat(SvgIcon): add custom class support (#8155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 增加样式支持 * test: 补充单元测试 --- src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs | 1 + test/UnitTest/Components/SvgIconTest.cs | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs b/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs index 1d61cdb98ee..8bb0db492cf 100644 --- a/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs +++ b/src/BootstrapBlazor/Components/Icon/SvgIcon.razor.cs @@ -32,6 +32,7 @@ public partial class SvgIcon /// private string? ClassString => CssBuilder.Default("bb-svg-icon") .AddClass($"bb-svg-icon-{Name}", !string.IsNullOrEmpty(Name)) + .AddClassFromAttributes(AdditionalAttributes) .Build(); private string? _hrefString; diff --git a/test/UnitTest/Components/SvgIconTest.cs b/test/UnitTest/Components/SvgIconTest.cs index 9cb226ee9dd..7e889c6c51f 100644 --- a/test/UnitTest/Components/SvgIconTest.cs +++ b/test/UnitTest/Components/SvgIconTest.cs @@ -25,4 +25,15 @@ public void SvgIcon_Ok() }); cut.MarkupMatches("
"); } + + [Fact] + public void CustomClass_Ok() + { + var cut = Context.Render(pb => + { + pb.Add(a => a.Name, "test-icon"); + pb.AddUnmatched("class", "custom-class"); + }); + cut.MarkupMatches("
"); + } } From 3cb9f711a0fafb73ce43fe22e028705196d5b505 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 24 Jun 2026 12:59:30 +0800 Subject: [PATCH 28/44] feat(TreeViewItem): add IconTemplate parameter (#8157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(TreeNodeBase): 增加 svg 雪碧图支持 * feat(TreeView): 支持自定义节点图标 * feat(TreeView): 增加 IconTemplate 参数 * feat: 增加样式支持 * feat: 增加样式 * chore: bump version 10.7.3-beta02 --- src/BootstrapBlazor/BootstrapBlazor.csproj | 2 +- .../Components/TreeView/TreeViewRow.razor | 15 +++++++++++++-- src/BootstrapBlazor/Misc/TreeNodeBase.cs | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/BootstrapBlazor/BootstrapBlazor.csproj b/src/BootstrapBlazor/BootstrapBlazor.csproj index 3173192f198..afd9756f6e9 100644 --- a/src/BootstrapBlazor/BootstrapBlazor.csproj +++ b/src/BootstrapBlazor/BootstrapBlazor.csproj @@ -1,7 +1,7 @@  - 10.7.3-beta01 + 10.7.3-beta02 diff --git a/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor b/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor index 0ee10125677..1a756023227 100644 --- a/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor +++ b/src/BootstrapBlazor/Components/TreeView/TreeViewRow.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @typeparam TItem @inherits ComponentBase @@ -20,7 +20,18 @@ @if (ShowIcon) { - + @if (Item.IconTemplate != null) + { + @Item.IconTemplate + } + else if (Item.UseSvg) + { + + } + else + { + + } } @if (Item.Template == null) { diff --git a/src/BootstrapBlazor/Misc/TreeNodeBase.cs b/src/BootstrapBlazor/Misc/TreeNodeBase.cs index b018f2468d9..bb9cfafde30 100644 --- a/src/BootstrapBlazor/Misc/TreeNodeBase.cs +++ b/src/BootstrapBlazor/Misc/TreeNodeBase.cs @@ -23,6 +23,24 @@ public abstract class TreeNodeBase : NodeBase /// public string? Icon { get; set; } + /// + /// 获得/设置 是否使用 Svg 图标 默认 false + /// Gets or sets whether to use Svg icon, default is false + /// + public bool UseSvg { get; set; } + + /// + /// 获得/设置 Svg 图标地址 + /// Gets or sets Svg icon URL + /// + public string? SvgHref { get; set; } + + /// + /// 获得/设置 自定义 Icon 模板 默认为 null + /// Gets or sets custom Icon template, default is null + /// + public RenderFragment? IconTemplate { get; set; } + /// /// 获得/设置 展开节点状态下的 Icon 图标 /// Gets or sets Icon when node is expanded From 568f40426f0ac4a73eacc82c5f9326aa0a524343 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 24 Jun 2026 15:47:49 +0800 Subject: [PATCH 29/44] test(TreeView): add IconTempalte unit test (#8160) --- test/UnitTest/Components/TreeViewTest.cs | 48 ++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/UnitTest/Components/TreeViewTest.cs b/test/UnitTest/Components/TreeViewTest.cs index a7be5b16cb7..c0ce52f196b 100644 --- a/test/UnitTest/Components/TreeViewTest.cs +++ b/test/UnitTest/Components/TreeViewTest.cs @@ -907,6 +907,54 @@ public void ExpandIcon_Ok() cut.Contains("test-expand-icon"); } + [Fact] + public void IconTemplate_Ok() + { + var items = TreeFoo.GetTreeItems(); + items[0].IconTemplate = builder => + { + builder.OpenElement(0, "span"); + builder.AddAttribute(1, "class", "test-icon-template"); + builder.CloseElement(); + }; + var cut = Context.Render>(pb => + { + pb.Add(a => a.ShowIcon, true); + pb.Add(a => a.Items, items); + }); + + // 自定义图标模板渲染成功 + cut.Contains("test-icon-template"); + + // 自定义图标模板优先级最高,未渲染 SvgIcon 与默认 i 标签 + var node = cut.FindAll(".tree-content .tree-node")[0]; + Assert.Contains("test-icon-template", node.InnerHtml); + Assert.DoesNotContain("bb-svg-icon", node.InnerHtml); + } + + [Fact] + public void UseSvg_Ok() + { + var items = TreeFoo.GetTreeItems(); + items[0].UseSvg = true; + items[0].SvgHref = "./test-tree.svg"; + items[0].Icon = "test-svg-icon"; + var cut = Context.Render>(pb => + { + pb.Add(a => a.ShowIcon, true); + pb.Add(a => a.Items, items); + }); + + // 使用 SvgIcon 渲染节点图标 + var icon = cut.FindComponent(); + Assert.Equal("test-svg-icon", icon.Instance.Name); + Assert.Equal("./test-tree.svg", icon.Instance.Href); + + cut.Contains("bb-svg-icon-test-svg-icon"); + cut.Contains("./test-tree.svg#test-svg-icon"); + cut.Contains("tree-icon"); + } + [Fact] public void ModelEqualityComparer_Ok() { From 9a274326cc01041a01bdaee272cd026f0d29209e Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 24 Jun 2026 15:52:47 +0800 Subject: [PATCH 30/44] feat(Transfer): add OnDisabledCallback parameter (#8158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 增加 OnDisabledCallback 参数 * test: 补充单元测试 * feat: 增加禁用项不能选中逻辑 * test: 增加单元测试 --- .../Components/Transfer/Transfer.razor | 8 +- .../Components/Transfer/Transfer.razor.cs | 8 ++ .../Components/Transfer/TransferPanel.razor | 7 +- .../Transfer/TransferPanel.razor.cs | 35 +++++--- test/UnitTest/Components/TransferPanelTest.cs | 85 +++++++++++++++++++ 5 files changed, 127 insertions(+), 16 deletions(-) diff --git a/src/BootstrapBlazor/Components/Transfer/Transfer.razor b/src/BootstrapBlazor/Components/Transfer/Transfer.razor index 98aa9c2d8a2..651b4240527 100644 --- a/src/BootstrapBlazor/Components/Transfer/Transfer.razor +++ b/src/BootstrapBlazor/Components/Transfer/Transfer.razor @@ -1,4 +1,4 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @typeparam TValue @inherits ValidateBase @@ -10,7 +10,8 @@ + OnDisabledCallback="OnDisabledCallback" + HeaderTemplate="LeftHeaderTemplate" ItemTemplate="LeftItemTemplate">
diff --git a/src/BootstrapBlazor/Components/Transfer/Transfer.razor.cs b/src/BootstrapBlazor/Components/Transfer/Transfer.razor.cs index 5c01fa8c2f8..816a1ffa3e2 100644 --- a/src/BootstrapBlazor/Components/Transfer/Transfer.razor.cs +++ b/src/BootstrapBlazor/Components/Transfer/Transfer.razor.cs @@ -185,6 +185,14 @@ public partial class Transfer [NotNull] public Func? OnSetItemClass { get; set; } + /// + /// 获得/设置 组件是否被禁用回调方法,默认为 null + /// Gets or sets the callback method for whether the component is disabled. Default is null + /// v10.7.3 + /// + [Parameter] + public Func? OnDisabledCallback { get; set; } + /// /// 获得/设置 左侧 Panel Header 模板 /// Gets or sets the left Panel Header template diff --git a/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor b/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor index fa6fd97ae75..283940e168a 100644 --- a/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor +++ b/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor @@ -1,11 +1,12 @@ -@namespace BootstrapBlazor.Components +@namespace BootstrapBlazor.Components @inherits BootstrapComponentBase
@if(HeaderTemplate == null) { - } else @@ -30,7 +31,7 @@ if(ItemTemplate == null) { var state = item.Active ? CheckboxState.Checked : CheckboxState.UnChecked; - } diff --git a/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor.cs b/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor.cs index 3f82fcecaa5..849a260752c 100644 --- a/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor.cs +++ b/src/BootstrapBlazor/Components/Transfer/TransferPanel.razor.cs @@ -114,6 +114,14 @@ public partial class TransferPanel [Parameter] public bool IsDisabled { get; set; } + /// + /// 获得/设置 组件是否被禁用回调方法,默认为 null + /// Gets or sets the callback method for whether the component is disabled. Default is null + /// v10.7.3 + /// + [Parameter] + public Func? OnDisabledCallback { get; set; } + /// /// 获得/设置 Header 模板 /// Gets or sets the header template @@ -157,11 +165,14 @@ protected override void OnParametersSet() protected CheckboxState HeaderCheckState() { var ret = CheckboxState.Indeterminate; - if (Items.Count > 0 && Items.All(i => i.Active)) + + // 全选状态仅根据未被 OnDisabledCallback 禁用的项进行计算 + var items = Items.Where(i => !IsItemDisabled(i)).ToList(); + if (items.Count > 0 && items.All(i => i.Active)) { ret = CheckboxState.Checked; } - else if (!Items.Any(i => i.Active)) + else if (!items.Any(i => i.Active)) { ret = CheckboxState.UnChecked; } @@ -177,14 +188,9 @@ protected async Task OnHeaderCheck(CheckboxState state, SelectedItem item) { if (Items != null) { - if (state == CheckboxState.Checked) - { - GetShownItems().ForEach(i => i.Active = true); - } - else - { - GetShownItems().ForEach(i => i.Active = false); - } + // 全选时过滤掉被 OnDisabledCallback 禁用的项,保持其原有选中状态 + var active = state == CheckboxState.Checked; + GetShownItems().Where(i => !IsItemDisabled(i)).ToList().ForEach(i => i.Active = active); if (OnSelectedItemsChanged != null) { @@ -248,4 +254,13 @@ protected void ClearSearch() private List GetShownItems() => (string.IsNullOrEmpty(SearchText) ? Items : Items.Where(i => i.Text.Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToList()); + + private bool GetDisableState(SelectedItem? item) => OnDisabledCallback != null + ? OnDisabledCallback(item) + : IsDisabled; + + /// + /// 判断指定数据项是否被 禁用,全选时禁用项将被过滤 + /// + private bool IsItemDisabled(SelectedItem item) => OnDisabledCallback?.Invoke(item) == true; } diff --git a/test/UnitTest/Components/TransferPanelTest.cs b/test/UnitTest/Components/TransferPanelTest.cs index 822c98bd86e..6fef790d159 100644 --- a/test/UnitTest/Components/TransferPanelTest.cs +++ b/test/UnitTest/Components/TransferPanelTest.cs @@ -80,6 +80,91 @@ public void IsDisabled_Ok() cut.Contains("disabled=\"disabled\""); } + [Fact] + public void OnDisabledCallback_Ok() + { + // 回调根据 item 决定禁用状态:Header(null) 与 Value 为 "1" 的项被禁用 + var cut = Context.Render(pb => + { + pb.Add(a => a.Items, + [ + new("1", "Test1"), + new("2", "Test2") + ]); + pb.Add(a => a.OnDisabledCallback, item => item == null || item.Value == "1"); + }); + + // 索引 0 为 Header,回调返回 true 被禁用 + var checkboxes = cut.FindComponents>(); + Assert.True(checkboxes[0].Instance.IsDisabled); + + // 索引 1 为 Test1,回调返回 true 被禁用 + Assert.True(checkboxes[1].Instance.IsDisabled); + + // 索引 2 为 Test2,回调返回 false 未禁用 + Assert.False(checkboxes[2].Instance.IsDisabled); + + // 设置了回调时优先使用回调结果:IsDisabled=true 但回调返回 false 时仍未禁用 + cut.Render(pb => + { + pb.Add(a => a.IsDisabled, true); + pb.Add(a => a.OnDisabledCallback, item => false); + }); + checkboxes = cut.FindComponents>(); + Assert.All(checkboxes, c => Assert.False(c.Instance.IsDisabled)); + } + + [Fact] + public async Task OnHeaderCheck_FilterDisabledItem_Ok() + { + List items = + [ + new("1", "Test1"), + new("2", "Test2") + ]; + var cut = Context.Render(pb => + { + pb.Add(a => a.Items, items); + // 仅禁用 Value 为 "1" 的项,Header(null) 不禁用以便点击全选 + pb.Add(a => a.OnDisabledCallback, item => item?.Value == "1"); + }); + + // 点击 Header 触发全选 + var checkbox = cut.FindComponent>(); + await cut.InvokeAsync(() => checkbox.Instance.SetState(CheckboxState.Checked)); + + // 全选时禁用项被过滤,保持未选中状态 + Assert.False(items[0].Active); + + // 未禁用项被选中 + Assert.True(items[1].Active); + + // 取消全选时禁用项同样被过滤,保持原有状态 + await cut.InvokeAsync(() => checkbox.Instance.SetState(CheckboxState.UnChecked)); + Assert.False(items[0].Active); + Assert.False(items[1].Active); + } + + [Fact] + public void HeaderCheckState_FilterDisabledItem_Ok() + { + // 禁用项 "1" 未选中,未禁用项 "2" 已选中 + List items = + [ + new("1", "Test1") { Active = false }, + new("2", "Test2") { Active = true } + ]; + var cut = Context.Render(pb => + { + pb.Add(a => a.Items, items); + pb.Add(a => a.OnDisabledCallback, item => item?.Value == "1"); + }); + + // 禁用项不参与全选状态计算,未禁用项全部选中时 Header 为 Checked + var checkbox = cut.FindComponent>(); + Assert.Equal(CheckboxState.Checked, checkbox.Instance.State); + } + [Fact] public void HeaderTemplate_Ok() { From 0f9f31fd0a5852395e594adfc077070416d6ce3b Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Sat, 11 Jul 2026 19:25:31 +0800 Subject: [PATCH 31/44] fix(Table): support toggling fixed columns at runtime link #8094 - sync runtime changes of ITableColumn.Fixed back to column states before rebuilding columns, so dynamic fixed columns survive column regeneration (AutoGenerateColumns/DynamicContext) - replay column states by state item directly, fix newly added columns losing Fixed value - invalidate fl/fr fixed column style caches when columns rebuilt - pass latest column states to updateColumnStates script, fix stale fixed state persisted to localStorage - use DefaultFixedColumnWidth in GetLeftStyle instead of hard-coded 200 Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.Sort.cs | 2 +- .../Components/Table/Table.razor.cs | 45 ++++- .../Components/Table/Table.razor.js | 18 +- test/UnitTest/Components/TableTest.cs | 170 ++++++++++++++++++ 4 files changed, 220 insertions(+), 15 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs index ea6ca48e781..15785795cbe 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs @@ -358,7 +358,7 @@ string GetFixedHeaderStyleString() => IsFixedHeader private string? GetLeftStyle(ITableColumn col) { var columns = GetVisibleColumns(); - var defaultWidth = 200; + var defaultWidth = DefaultFixedColumnWidth; var width = 0; var start = 0; var index = columns.IndexOf(col); diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 15198edc558..a3d01521d28 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web.Virtualization; +using System.Collections.Concurrent; using System.Reflection; namespace BootstrapBlazor.Components; @@ -1322,6 +1323,9 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { + // 重建列信息前先同步运行时变更的固定列状态,支持动态设置固定列 + SyncColumnsFixedState(); + // 构建列信息 var cols = GetTableColumns(); @@ -1400,9 +1404,37 @@ private async Task LoadTableColumnStates() } } + /// + /// 列固定状态应用缓存 键为列实例 值为最后一次应用到该实例上的固定状态 用于识别运行时变更 + /// Applied fixed state cache keyed by column instance. Used to detect runtime changes + /// + private readonly ConcurrentDictionary _appliedFixedColumnCache = new(ReferenceEqualityComparer.Instance); + + private void SyncColumnsFixedState() + { + foreach (var col in Columns) + { + // 仅同步应用过固定状态的列实例,避免覆盖持久化恢复的状态 + if (_appliedFixedColumnCache.TryGetValue(col, out var applied) && applied != col.Fixed) + { + var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); + if (state != null) + { + state.Fixed = col.Fixed; + state.Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width; + } + } + } + } + private void ResetTableColumns() { _visibleColumnsCache.Clear(); + _appliedFixedColumnCache.Clear(); + + // 固定列变化影响相邻列的 fl fr 样式 重建时清除缓存重新计算 + FirstFixedColumnCache.Clear(); + LastFixedColumnCache.Clear(); if (_tableColumnStates.Count == 0) { @@ -1417,6 +1449,7 @@ private void ResetTableColumns() var state = CreateTableColumnState(col); _tableColumnStates.Add(state); + _appliedFixedColumnCache[col] = col.Fixed; if (col.GetVisible(_screenSize)) { @@ -1484,10 +1517,12 @@ private void ResetTableColumns() { var item = _tableColumnStates[index]; var col = columnMap[item.Name]; - col.Fixed = !string.IsNullOrEmpty(item.Name) && stateMap.TryGetValue(item.Name, out var stateF) ? stateMap[item.Name].Fixed : false; - // 这里一定要使用 stateMap[item.Name].Width 而不是 item.Width 因为 item.Width 可能是 null; - // 以解决持久化回来时,列宽为_visibleColumnsCache里面列宽为null 的问题导致固定列上style时值不正确的问题 - col.Width = !string.IsNullOrEmpty(item.Name) && stateMap.TryGetValue(item.Name, out var stateW) ? stateMap[item.Name].Width: col.Width; + + // 将列状态回放到列实例上 解决持久化恢复与动态固定列问题 + col.Fixed = item.Fixed; + col.Width = item.Width ?? col.Width; + _appliedFixedColumnCache[col] = col.Fixed; + if (item.Visible) { // 增加到可见列缓存集合 @@ -2017,7 +2052,7 @@ public async Task UpdateTableColumnClientStatus() StateHasChanged(); } // 如果启用了 ClientTableName 则更新浏览器持久化列状态 - await InvokeVoidAsync("updateColumnStates", Id); + await InvokeVoidAsync("updateColumnStates", Id, _tableColumnStates); return _tableColumnStateCache; } diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 468f0f6238d..b8066413058 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -970,17 +970,17 @@ const removeColumnWidthState = tableName => { localStorage.removeItem(columnWidthKey); } -export function updateColumnStates(id) { - const el = document.getElementById(id) - if (el === null) { +export function updateColumnStates(id, columnStates) { + const table = Data.get(id) + if (!table) { return - } - let table = Data.get(id) - table.el = el; - Data.set(id, table) - const state = getColumnStateObject(table); - saveColumnStateToLocalstorage(table, state); + } + if (columnStates) { + table.options.columnStates = columnStates; + } + saveColumnStateToLocalstorage(table); } + export function clearColumnStates(tableName) { localStorage.removeItem(tableName); localStorage.removeItem(`bb-table-${tableName}`); diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 32b3b9b2fa5..14314b16d2a 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9078,6 +9078,176 @@ public async Task UpdateTableColumnClientStatus_Ok() } } + [Fact] + public async Task DynamicFixedColumn_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Count); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, "Count", typeof(int))); + builder.AddAttribute(7, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(8); + builder.AddAttribute(9, "Field", foo.Address); + builder.AddAttribute(10, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 初始无固定列 + Assert.DoesNotContain("table-fixed-column", cut.Markup); + + // 运行时设置前两列固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + Assert.Contains("table-fixed-column", cut.Markup); + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("left: 0px;", columns[0].OuterHtml); + Assert.Contains("fixed", columns[1].ClassName); + + // 最后一个左固定列应包含 fr 样式 + Assert.Contains("fr", columns[1].ClassName); + Assert.Contains("left: 100px;", columns[1].OuterHtml); + Assert.DoesNotContain("fixed", columns[2].ClassName ?? ""); + + // 运行时取消固定列 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + }); + table.Render(); + + Assert.DoesNotContain("table-fixed-column", cut.Markup); + columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed", columns[0].ClassName ?? ""); + Assert.DoesNotContain("fixed", columns[1].ClassName ?? ""); + } + + [Fact] + public async Task DynamicFixedColumn_ClientTableName_Ok() + { + // 持久化状态中 Name 列为固定列 + var state = new TableColumnClientStatus(); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100, Fixed = true }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120, Fixed = false }); + + Context.JSInterop.Setup("getColumnStates", "test_dynamic_fixed").SetResult(state); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_dynamic_fixed"); + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Address); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 持久化状态恢复 Name 列固定 + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.True(table.Instance.Columns[0].Fixed); + + // 运行时取消固定 持久化恢复的状态不覆盖运行时变更 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed", columns[0].ClassName ?? ""); + Assert.False(table.Instance.Columns[0].Fixed); + } + + [Fact] + public async Task DynamicFixedColumn_AutoGenerateColumns_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.AutoGenerateColumns, true); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + }); + }); + + var table = cut.FindComponent>(); + Assert.DoesNotContain("table-fixed-column", cut.Markup); + + // 运行时设置前两列固定 模拟 #8094 场景 自动生成列每次渲染重建实例 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + Assert.Contains("table-fixed-column", cut.Markup); + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("fixed", columns[1].ClassName); + Assert.Contains("fr", columns[1].ClassName); + + // 再次渲染后固定状态保持 不被自动生成列重建逻辑重置 + table.Render(); + columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("fixed", columns[1].ClassName); + + // 运行时取消固定列 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + }); + table.Render(); + + Assert.DoesNotContain("table-fixed-column", cut.Markup); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() { From 07decf2d157e64657253412990f79d3fa306fa14 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 15 Jul 2026 17:37:47 +0800 Subject: [PATCH 32/44] fix(Table): use client rendered width when toggling fixed columns link #8094 - add getColumnWidths script to measure actual rendered column widths on client - backfill measured width instead of DefaultFixedColumnWidth when a column without explicit width is fixed at runtime, so sticky offsets match client rendering and width does not jump on toggle - track auto backfilled widths and restore browser auto layout when column is unfixed; keep user resized width - share the same logic in UpdateTableColumnClientStatus Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.cs | 72 +++++++++- .../Components/Table/Table.razor.js | 12 ++ test/UnitTest/Components/TableTest.cs | 132 ++++++++++++++++++ 3 files changed, 210 insertions(+), 6 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index c824520ef83..d1ce548700e 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1324,7 +1324,7 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { // 重建列信息前先同步运行时变更的固定列状态,支持动态设置固定列 - SyncColumnsFixedState(); + await SyncColumnsFixedStateAsync(); // 构建列信息 var cols = GetTableColumns(); @@ -1410,8 +1410,15 @@ private async Task LoadTableColumnStates() /// private readonly ConcurrentDictionary _appliedFixedColumnCache = new(ReferenceEqualityComparer.Instance); - private void SyncColumnsFixedState() + /// + /// 自动回填宽度缓存 键为列名称 值为固定列时回填的宽度 用于取消固定时还原 + /// Auto backfill width cache keyed by column name. Used to restore width when unfixed + /// + private readonly Dictionary _autoFixedColumnWidthCache = []; + + private async Task SyncColumnsFixedStateAsync() { + List<(ITableColumn Column, TableColumnState State)>? changedColumns = null; foreach (var col in Columns) { // 仅同步应用过固定状态的列实例,避免覆盖持久化恢复的状态 @@ -1420,11 +1427,62 @@ private void SyncColumnsFixedState() var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); if (state != null) { - state.Fixed = col.Fixed; - state.Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width; + changedColumns ??= []; + changedColumns.Add((col, state)); } } } + + if (changedColumns == null) + { + return; + } + + var clientWidths = await GetClientColumnWidthsAsync(changedColumns.Exists(i => i.Column is { Fixed: true, Width: null })); + foreach (var (col, state) in changedColumns) + { + ApplyColumnFixedState(col, state, clientWidths); + } + } + + /// + /// 获得客户端实际渲染列宽集合 未固定列无显式宽度时其实际宽度由浏览器布局决定 服务器端无法得知 + /// Get actual rendered column widths from client. Browser decides width of columns without explicit width + /// + private async Task?> GetClientColumnWidthsAsync(bool required) => required + ? await InvokeAsync>("getColumnWidths", Id) + : null; + + private void ApplyColumnFixedState(ITableColumn col, TableColumnState state, Dictionary? clientWidths) + { + state.Fixed = col.Fixed; + if (col.Fixed) + { + if (col.Width.HasValue) + { + state.Width = col.Width; + } + else + { + // 未显式设置宽度时使用客户端实际渲染宽度,保证 sticky 偏移量与客户端一致且切换时宽度不跳变 + var width = clientWidths?.TryGetValue(state.Name, out var w) == true ? w : DefaultFixedColumnWidth; + state.Width = width; + _autoFixedColumnWidthCache[state.Name] = width; + } + } + else if (_autoFixedColumnWidthCache.Remove(state.Name, out var applied)) + { + // 取消固定时还原自动回填的宽度恢复浏览器自动布局,用户拖拽调整过列宽时保留调整值 + if (state.Width == applied) + { + state.Width = null; + col.Width = null; + } + } + else + { + state.Width = col.Width; + } } private void ResetTableColumns() @@ -2044,14 +2102,15 @@ public async Task UpdateTableColumnClientStatus() { if (Columns.Count != 0) { + var clientWidths = await GetClientColumnWidthsAsync(Columns.Exists(i => i is { Fixed: true, Width: null })); + // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 foreach (var item in Columns) { var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); if (columnState != null) { - columnState.Fixed = item.Fixed; - columnState.Width = item.Fixed && !item.Width.HasValue ? DefaultFixedColumnWidth : item.Width; + ApplyColumnFixedState(item, columnState, clientWidths); } } StateHasChanged(); @@ -2077,6 +2136,7 @@ public async Task ClearTableColumnClientStatus() // 清除缓存的列状态 _tableColumnStateCache.Clear(); + _autoFixedColumnWidthCache.Clear(); StateHasChanged(); } diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index b8066413058..535ca2a9c53 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -970,6 +970,18 @@ const removeColumnWidthState = tableName => { localStorage.removeItem(columnWidthKey); } +export function getColumnWidths(id) { + const table = Data.get(id) + if (!table || !table.tables || table.tables.length === 0) { + return null + } + const widths = {} + table.tables[0].querySelectorAll('thead > tr > th[data-bb-field]').forEach(th => { + widths[th.getAttribute('data-bb-field')] = Math.round(getWidth(th)) + }) + return widths +} + export function updateColumnStates(id, columnStates) { const table = Data.get(id) if (!table) { diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index a27887c3f79..69b08b947e6 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9248,6 +9248,138 @@ await cut.InvokeAsync(() => Assert.DoesNotContain("table-fixed-column", cut.Markup); } + [Fact] + public async Task DynamicFixedColumn_ClientWidth_Ok() + { + // 模拟客户端实际渲染宽度 未显式设置 Width 的列固定时使用实际宽度而不是默认宽度 200 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 }, + { nameof(Foo.Address), 180 }, + { nameof(Foo.Count), 80 } + }); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(6); + builder.AddAttribute(7, "Field", foo.Count); + builder.AddAttribute(8, "FieldExpression", Utility.GenerateValueExpression(foo, "Count", typeof(int))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时固定前两列 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + var columns = cut.FindAll("thead th"); + Assert.Contains("left: 0px;", columns[0].OuterHtml); + + // 第二列偏移量使用第一列客户端实际宽度 120 而不是默认宽度 200 + Assert.Contains("left: 120px;", columns[1].OuterHtml); + + // colgroup 应用实际宽度 固定期间列宽保持稳定不跳变 + var colGroup = table.Find("colgroup"); + Assert.Contains("width: 120px;", colGroup.ToMarkup()); + Assert.Contains("width: 180px;", colGroup.ToMarkup()); + + // 取消固定后还原自动回填宽度 恢复浏览器自动布局 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + table.Instance.Columns[1].Fixed = false; + }); + table.Render(); + + Assert.False(table.Instance.Columns[0].Width.HasValue); + Assert.False(table.Instance.Columns[1].Width.HasValue); + colGroup = table.Find("colgroup"); + Assert.DoesNotContain("width: 120px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 180px;", colGroup.ToMarkup()); + } + + [Fact] + public async Task DynamicFixedColumn_ResizedWidth_Ok() + { + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 }, + { nameof(Foo.Address), 180 } + }); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时固定第一列 使用客户端实际宽度 120 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + Assert.Equal(120, table.Instance.Columns[0].Width); + + // 模拟用户固定期间拖拽调整列宽 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Width = 150; + }); + await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); + + // 取消固定后保留用户调整值 不还原自动回填宽度 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + + Assert.Equal(150, table.Instance.Columns[0].Width); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() { From 22506d3b27b2117c3d41433a525c7ca95e3f94e7 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Wed, 15 Jul 2026 18:09:21 +0800 Subject: [PATCH 33/44] fix(Table): incorrect fixed-right when toggling fixed columns at runtime link #8094 An isolated fixed column (preceded by a non-fixed visible column, e.g. when column state order restored from localStorage differs from Columns order) was treated as fixed-right and its right offset summed all following non-fixed column widths. Treat a column as fixed-right only when it and all following columns are fixed (a fixed suffix); otherwise fall back to left fixed. Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.Sort.cs | 4 +- test/UnitTest/Components/TableTest.cs | 86 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs index 15785795cbe..5b45b017280 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs @@ -320,7 +320,9 @@ private bool IsFixRight(ITableColumn col) // 获得当前列索引 var index = columns.IndexOf(col); - return !columns.Take(index).All(i => i.Fixed); + // 前缀固定列判定为左固定,该列到最后一列全部固定(构成固定后缀)时判定为右固定 + // 孤立的中间固定列回落为左固定,避免动态切换固定列时误判为右固定 + return !columns.Take(index).All(i => i.Fixed) && columns.Skip(index).All(i => i.Fixed); } /// diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 69b08b947e6..97cd0adc462 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9322,6 +9322,92 @@ await cut.InvokeAsync(() => Assert.DoesNotContain("width: 180px;", colGroup.ToMarkup()); } + [Fact] + public async Task DynamicFixedColumn_FixRight_Ok() + { + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Address); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.AddAttribute(7, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(8); + builder.AddAttribute(9, "Field", foo.Count); + builder.AddAttribute(10, "FieldExpression", Utility.GenerateValueExpression(foo, "Count", typeof(int))); + builder.AddAttribute(11, "Width", 100); + builder.CloseComponent(); + + builder.OpenComponent>(12); + builder.AddAttribute(13, "Field", foo.Complete); + builder.AddAttribute(14, "FieldExpression", Utility.GenerateValueExpression(foo, "Complete", typeof(bool))); + builder.AddAttribute(15, "Width", 100); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 固定最后两列 构成固定后缀 判定为右固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[2].Fixed = true; + table.Instance.Columns[3].Fixed = true; + }); + table.Render(); + + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed-right", columns[2].ClassName); + Assert.Contains("right: 100px;", columns[2].OuterHtml); + Assert.Contains("fixed-right", columns[3].ClassName); + Assert.Contains("right: 0px;", columns[3].OuterHtml); + + // 取消固定后 固定中间单列 不构成固定后缀 回落为左固定 不应误判为右固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[2].Fixed = false; + table.Instance.Columns[3].Fixed = false; + }); + table.Render(); + await cut.InvokeAsync(() => + { + table.Instance.Columns[1].Fixed = true; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed-right", columns[1].ClassName); + Assert.Contains("left: 100px;", columns[1].OuterHtml); + + // 全部列固定 判定为左固定 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + table.Instance.Columns[2].Fixed = true; + table.Instance.Columns[3].Fixed = true; + }); + table.Render(); + + columns = cut.FindAll("thead th"); + Assert.All(columns, th => Assert.DoesNotContain("fixed-right", th.ClassName ?? "")); + Assert.Contains("left: 300px;", columns[3].OuterHtml); + } + [Fact] public async Task DynamicFixedColumn_ResizedWidth_Ok() { From 21f05adb3d18c476ac0a4a0d8c77e9430399f0d5 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 16 Jul 2026 09:56:29 +0800 Subject: [PATCH 34/44] fix(Table): exclude non-fixed columns from sticky offset calculation link #8094 GetLeftStyle/GetRightStyle accumulated widths of all preceding/following visible columns including non-fixed ones, guessing DefaultFixedColumnWidth for columns without explicit width. Non-fixed columns scroll out of the viewport and must not contribute to the sticky left/right offset. Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.Sort.cs | 14 ++++++++++++-- test/UnitTest/Components/TableTest.cs | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs index 5b45b017280..369ce658905 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.Sort.cs @@ -379,7 +379,12 @@ string GetFixedHeaderStyleString() => IsFixedHeader while (index > start) { var column = columns[start++]; - width += column.Width ?? defaultWidth; + + // 仅累加左固定列宽度 未固定列滚动时移出视口 不参与 sticky 偏移 + if (column.Fixed) + { + width += column.Width ?? defaultWidth; + } } return $"left: {width}px;"; } @@ -395,7 +400,12 @@ string GetFixedHeaderStyleString() => IsFixedHeader for (var i = index + 1; i < columns.Count; i++) { var column = columns[i]; - width += column.Width ?? defaultWidth; + + // 仅累加右固定列宽度 未固定列滚动时移出视口 不参与 sticky 偏移 + if (column.Fixed) + { + width += column.Width ?? defaultWidth; + } } if (ShowExtendButtons && FixedExtendButtonsColumn) { diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 97cd0adc462..dbfeb96b074 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9392,7 +9392,9 @@ await cut.InvokeAsync(() => columns = cut.FindAll("thead th"); Assert.DoesNotContain("fixed-right", columns[1].ClassName); - Assert.Contains("left: 100px;", columns[1].OuterHtml); + + // 孤立固定列前面无固定列 偏移不累加未固定列宽度 + Assert.Contains("left: 0px;", columns[1].OuterHtml); // 全部列固定 判定为左固定 await cut.InvokeAsync(() => From cbe1cb409bf75b562050e9543190dbc958270a07 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 16 Jul 2026 12:09:22 +0800 Subject: [PATCH 35/44] fix(Table): eliminate layout shift when toggling fixed columns at runtime link #8094 - refresh a client column width snapshot after each render instead of awaiting script measurement inside the render pipeline, which made RenderTemplate render empty content and rebuild the table DOM, invalidating client script element references - in auto table layout col width behaves as content-box while measured widths are border-box, so applied widths rendered 2*padding+border wider and sticky offsets misaligned; backfill measured widths for all columns and switch to fixed table layout while fixed columns exist so colgroup widths apply exactly with zero width jump - restore all backfilled widths and auto layout when no fixed column remains; keep user resized widths Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.cs | 135 ++++++++++++------ test/UnitTest/Components/TableTest.cs | 6 + 2 files changed, 101 insertions(+), 40 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index d1ce548700e..f9233b2e786 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -91,7 +91,7 @@ public partial class Table : ITable, IModelEqualityComparer where .AddClass("table-excel", IsExcel) .AddClass("table-bordered", IsBordered) .AddClass("table-striped table-hover", IsStriped) - .AddClass("table-layout-fixed", IsFixedHeader) + .AddClass("table-layout-fixed", IsFixedHeader || IsFixedColumnLayout) .AddClass("table-draggable", AllowDragColumn) .Build(); @@ -118,6 +118,19 @@ public partial class Table : ITable, IModelEqualityComparer where private bool FixedColumn => FixedExtendButtonsColumn || FixedMultipleColumn || FixedDetailRowHeaderColumn || FixedLineNoColumn || Columns.Any(c => c.Fixed); + /// + /// 存在固定列且所有可见列均有宽度时使用 fixed 布局 保证 colgroup 宽度精确生效与 sticky 偏移一致 + /// Use fixed table layout when fixed columns exist and all visible columns have explicit width + /// + private bool IsFixedColumnLayout + { + get + { + var columns = GetVisibleColumns(); + return columns.Count > 0 && columns.All(i => i.Width.HasValue) && columns.Any(i => i.Fixed); + } + } + /// /// 获得 Body 内行样式 /// Get Body Row CSS Class @@ -1324,7 +1337,7 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { // 重建列信息前先同步运行时变更的固定列状态,支持动态设置固定列 - await SyncColumnsFixedStateAsync(); + SyncColumnsFixedState(); // 构建列信息 var cols = GetTableColumns(); @@ -1416,9 +1429,24 @@ private async Task LoadTableColumnStates() /// private readonly Dictionary _autoFixedColumnWidthCache = []; - private async Task SyncColumnsFixedStateAsync() + /// + /// 客户端实际渲染列宽快照 渲染结束后由脚本测量更新 未显式设置宽度列的实际宽度由浏览器布局决定 服务器端无法得知 + /// Snapshot of actual rendered column widths measured by script after each render + /// + private Dictionary? _clientColumnWidths; + + private async Task RefreshClientColumnWidthsAsync() + { + var widths = await InvokeAsync>("getColumnWidths", Id); + if (widths != null) + { + _clientColumnWidths = widths; + } + } + + private void SyncColumnsFixedState() { - List<(ITableColumn Column, TableColumnState State)>? changedColumns = null; + var changed = false; foreach (var col in Columns) { // 仅同步应用过固定状态的列实例,避免覆盖持久化恢复的状态 @@ -1427,61 +1455,77 @@ private async Task SyncColumnsFixedStateAsync() var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); if (state != null) { - changedColumns ??= []; - changedColumns.Add((col, state)); + ApplyColumnFixedState(col, state); + changed = true; } } } - if (changedColumns == null) + if (changed) { - return; + SyncColumnsWidthState(); } + } - var clientWidths = await GetClientColumnWidthsAsync(changedColumns.Exists(i => i.Column is { Fixed: true, Width: null })); - foreach (var (col, state) in changedColumns) + private void ApplyColumnFixedState(ITableColumn col, TableColumnState state) + { + _appliedFixedColumnCache[col] = col.Fixed; + + state.Fixed = col.Fixed; + if (col.Fixed && !col.Width.HasValue) + { + // 未显式设置宽度时使用客户端实际渲染宽度,保证 sticky 偏移量与客户端一致且切换时宽度不跳变 + var width = _clientColumnWidths?.TryGetValue(state.Name, out var w) == true ? w : DefaultFixedColumnWidth; + state.Width = width; + _autoFixedColumnWidthCache[state.Name] = width; + } + else { - ApplyColumnFixedState(col, state, clientWidths); + state.Width = col.Width; } } - /// - /// 获得客户端实际渲染列宽集合 未固定列无显式宽度时其实际宽度由浏览器布局决定 服务器端无法得知 - /// Get actual rendered column widths from client. Browser decides width of columns without explicit width - /// - private async Task?> GetClientColumnWidthsAsync(bool required) => required - ? await InvokeAsync>("getColumnWidths", Id) - : null; - - private void ApplyColumnFixedState(ITableColumn col, TableColumnState state, Dictionary? clientWidths) + private void SyncColumnsWidthState() { - state.Fixed = col.Fixed; - if (col.Fixed) + if (Columns.Exists(i => i.Fixed)) { - if (col.Width.HasValue) + if (_clientColumnWidths == null) { - state.Width = col.Width; + return; } - else + + // 存在固定列时回填所有未显式设置宽度列的客户端实际渲染宽度 + // 全列宽度已知后启用 fixed 布局 colgroup 宽度精确生效 保证 sticky 偏移与实际渲染一致且切换时无宽度跳变 + foreach (var col in Columns) { - // 未显式设置宽度时使用客户端实际渲染宽度,保证 sticky 偏移量与客户端一致且切换时宽度不跳变 - var width = clientWidths?.TryGetValue(state.Name, out var w) == true ? w : DefaultFixedColumnWidth; - state.Width = width; - _autoFixedColumnWidthCache[state.Name] = width; + if (!col.Width.HasValue) + { + var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); + if (state != null && _clientColumnWidths.TryGetValue(state.Name, out var width)) + { + state.Width = width; + _autoFixedColumnWidthCache[state.Name] = width; + } + } } } - else if (_autoFixedColumnWidthCache.Remove(state.Name, out var applied)) + else { - // 取消固定时还原自动回填的宽度恢复浏览器自动布局,用户拖拽调整过列宽时保留调整值 - if (state.Width == applied) + // 无固定列时还原全部自动回填宽度恢复浏览器自动布局 用户拖拽调整过列宽时保留调整值 + foreach (var (name, applied) in _autoFixedColumnWidthCache) { - state.Width = null; - col.Width = null; + var state = _tableColumnStates.Find(i => i.Name == name); + if (state != null && state.Width == applied) + { + state.Width = null; + var col = Columns.Find(i => i.GetFieldName() == name); + if (col != null) + { + col.Width = null; + } + } } - } - else - { - state.Width = col.Width; + _autoFixedColumnWidthCache.Clear(); } } @@ -1651,6 +1695,13 @@ private async Task OnTableRenderAsync(bool firstRender) { await OnAfterRenderCallback(this, firstRender); } + + // 渲染结束后刷新客户端实际列宽快照供运行时切换固定列时使用 + // 不可在渲染管线中等待脚本测量 否则 RenderTemplate 会渲染空内容导致表格重建 客户端脚本引用失效 + if (Columns.Exists(i => !i.Width.HasValue)) + { + await RefreshClientColumnWidthsAsync(); + } } private string? GetTableStyleString(bool hasHeader) @@ -2102,7 +2153,10 @@ public async Task UpdateTableColumnClientStatus() { if (Columns.Count != 0) { - var clientWidths = await GetClientColumnWidthsAsync(Columns.Exists(i => i is { Fixed: true, Width: null })); + if (Columns.Exists(i => i.Fixed) && Columns.Exists(i => !i.Width.HasValue)) + { + await RefreshClientColumnWidthsAsync(); + } // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 foreach (var item in Columns) @@ -2110,9 +2164,10 @@ public async Task UpdateTableColumnClientStatus() var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); if (columnState != null) { - ApplyColumnFixedState(item, columnState, clientWidths); + ApplyColumnFixedState(item, columnState); } } + SyncColumnsWidthState(); StateHasChanged(); } // 如果启用了 ClientTableName 则更新浏览器持久化列状态 diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index dbfeb96b074..94415c9afcd 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9307,6 +9307,10 @@ await cut.InvokeAsync(() => Assert.Contains("width: 120px;", colGroup.ToMarkup()); Assert.Contains("width: 180px;", colGroup.ToMarkup()); + // 未固定列同样回填实际宽度 全列宽度已知后启用 fixed 布局保证宽度精确生效 + Assert.Contains("width: 80px;", colGroup.ToMarkup()); + Assert.Contains("table-layout-fixed", cut.Markup); + // 取消固定后还原自动回填宽度 恢复浏览器自动布局 await cut.InvokeAsync(() => { @@ -9320,6 +9324,8 @@ await cut.InvokeAsync(() => colGroup = table.Find("colgroup"); Assert.DoesNotContain("width: 120px;", colGroup.ToMarkup()); Assert.DoesNotContain("width: 180px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 80px;", colGroup.ToMarkup()); + Assert.DoesNotContain("table-layout-fixed", cut.Markup); } [Fact] From 2440e99674d8a60a3bbab954ab6bc71f499dee70 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 16 Jul 2026 14:57:43 +0800 Subject: [PATCH 36/44] fix(Table): keep explicit width columns from resizing when toggling fixed columns link #8094 In auto table layout a column with explicit width renders wider than the specified value (plus cell padding and border), so switching to fixed layout when fixed columns exist made such columns visibly shrink. Override every column state width with the measured client width while fixed columns exist and remember the original value, restoring it when no fixed column remains. Skip the width sync in UpdateTableColumnClientStatus when the fixed state is unchanged so manually adjusted widths are not clobbered by a stale snapshot. Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.cs | 65 ++++++++++--------- test/UnitTest/Components/TableTest.cs | 63 ++++++++++++++++++ 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index f9233b2e786..008c937303e 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1424,10 +1424,10 @@ private async Task LoadTableColumnStates() private readonly ConcurrentDictionary _appliedFixedColumnCache = new(ReferenceEqualityComparer.Instance); /// - /// 自动回填宽度缓存 键为列名称 值为固定列时回填的宽度 用于取消固定时还原 + /// 自动回填宽度缓存 键为列名称 值为固定列时回填的宽度与原始宽度 用于取消固定时还原 /// Auto backfill width cache keyed by column name. Used to restore width when unfixed /// - private readonly Dictionary _autoFixedColumnWidthCache = []; + private readonly Dictionary _autoFixedColumnWidthCache = []; /// /// 客户端实际渲染列宽快照 渲染结束后由脚本测量更新 未显式设置宽度列的实际宽度由浏览器布局决定 服务器端无法得知 @@ -1472,56 +1472,54 @@ private void ApplyColumnFixedState(ITableColumn col, TableColumnState state) _appliedFixedColumnCache[col] = col.Fixed; state.Fixed = col.Fixed; - if (col.Fixed && !col.Width.HasValue) - { - // 未显式设置宽度时使用客户端实际渲染宽度,保证 sticky 偏移量与客户端一致且切换时宽度不跳变 - var width = _clientColumnWidths?.TryGetValue(state.Name, out var w) == true ? w : DefaultFixedColumnWidth; - state.Width = width; - _autoFixedColumnWidthCache[state.Name] = width; - } - else - { - state.Width = col.Width; - } + state.Width = col.Width; } private void SyncColumnsWidthState() { if (Columns.Exists(i => i.Fixed)) { - if (_clientColumnWidths == null) - { - return; - } - - // 存在固定列时回填所有未显式设置宽度列的客户端实际渲染宽度 - // 全列宽度已知后启用 fixed 布局 colgroup 宽度精确生效 保证 sticky 偏移与实际渲染一致且切换时无宽度跳变 + // 存在固定列时将所有列宽度覆盖为客户端实际渲染宽度并记录原始宽度用于还原 + // auto 布局下 colgroup 宽度按内容盒渲染与显式宽度不一致 全列覆盖实测宽度后启用 fixed 布局 + // colgroup 宽度精确生效 保证 sticky 偏移与实际渲染一致且切换时无宽度跳变 foreach (var col in Columns) { - if (!col.Width.HasValue) + var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); + if (state == null) { - var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); - if (state != null && _clientColumnWidths.TryGetValue(state.Name, out var width)) + continue; + } + + if (_clientColumnWidths != null && _clientColumnWidths.TryGetValue(state.Name, out var width)) + { + if (state.Width != width) { + var original = _autoFixedColumnWidthCache.TryGetValue(state.Name, out var exist) ? exist.Original : col.Width; + _autoFixedColumnWidthCache[state.Name] = (width, original); state.Width = width; - _autoFixedColumnWidthCache[state.Name] = width; } } + else if (col.Fixed && !col.Width.HasValue && state.Width == null) + { + // 无实测宽度时为固定列兜底默认宽度 + _autoFixedColumnWidthCache[state.Name] = (DefaultFixedColumnWidth, null); + state.Width = DefaultFixedColumnWidth; + } } } else { - // 无固定列时还原全部自动回填宽度恢复浏览器自动布局 用户拖拽调整过列宽时保留调整值 - foreach (var (name, applied) in _autoFixedColumnWidthCache) + // 无固定列时还原全部覆盖宽度恢复原始布局 用户拖拽调整过列宽时保留调整值 + foreach (var (name, item) in _autoFixedColumnWidthCache) { var state = _tableColumnStates.Find(i => i.Name == name); - if (state != null && state.Width == applied) + if (state != null && state.Width == item.Applied) { - state.Width = null; + state.Width = item.Original; var col = Columns.Find(i => i.GetFieldName() == name); if (col != null) { - col.Width = null; + col.Width = item.Original; } } } @@ -2159,15 +2157,22 @@ public async Task UpdateTableColumnClientStatus() } // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 + var fixedChanged = false; foreach (var item in Columns) { var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); if (columnState != null) { + fixedChanged |= columnState.Fixed != item.Fixed; ApplyColumnFixedState(item, columnState); } } - SyncColumnsWidthState(); + + // 固定状态变化或固定列缺少宽度时同步列宽度 其余场景跳过防止手动调整的列宽被实测快照覆盖 + if (fixedChanged || Columns.Exists(i => i.Fixed && !i.Width.HasValue)) + { + SyncColumnsWidthState(); + } StateHasChanged(); } // 如果启用了 ClientTableName 则更新浏览器持久化列状态 diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 94415c9afcd..36284b3e160 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9416,6 +9416,69 @@ await cut.InvokeAsync(() => Assert.Contains("left: 300px;", columns[3].OuterHtml); } + [Fact] + public async Task DynamicFixedColumn_ExplicitWidth_Ok() + { + // auto 布局下显式宽度列实际渲染宽度大于设置值(含 padding/border)固定时应使用实测宽度避免跳变 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 197 }, + { nameof(Foo.Address), 220 } + }); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Width", 180); + builder.CloseComponent(); + + builder.OpenComponent>(4); + builder.AddAttribute(5, "Field", foo.Address); + builder.AddAttribute(6, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 固定第一列 显式宽度 180 的列使用实测宽度 197 保证切换时渲染宽度不变 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + + var colGroup = table.Find("colgroup"); + Assert.Contains("width: 197px;", colGroup.ToMarkup()); + Assert.Contains("width: 220px;", colGroup.ToMarkup()); + Assert.Contains("table-layout-fixed", cut.Markup); + + // 取消固定后还原原始显式宽度 180 未设置宽度列还原为空 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + + Assert.Equal(180, table.Instance.Columns[0].Width); + Assert.False(table.Instance.Columns[1].Width.HasValue); + colGroup = table.Find("colgroup"); + Assert.Contains("width: 180px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 197px;", colGroup.ToMarkup()); + Assert.DoesNotContain("width: 220px;", colGroup.ToMarkup()); + Assert.DoesNotContain("table-layout-fixed", cut.Markup); + } + [Fact] public async Task DynamicFixedColumn_ResizedWidth_Ok() { From 95d1b24d33cf1194b092952f8806f7486f4c9a65 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 16 Jul 2026 17:07:41 +0800 Subject: [PATCH 37/44] refactor(Table): exclude fixed state from client column state persistence link #8094 The component has no built-in UI for end users to change column fixed state, so it is entirely code-driven and code is the source of truth. Persisting it to localStorage let stale client data override markup declarations, and the restored fixed state could never restore original widths after unfixing because the width overlay cache is in-memory only. - mark TableColumnState.Fixed with JsonIgnore so it is neither saved to nor restored from client persistence; stale fixed values in existing localStorage data are ignored on load - initialize restored column states with the column instance fixed value on first replay so markup declared fixed columns keep working - remove fixed field from the JS column state object Runtime state sync is unchanged: toggling fixed columns still survives column rebuilds within a session. Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.cs | 16 ++++++++++++++++ .../Components/Table/Table.razor.js | 6 ++---- .../Components/Table/TableColumnState.cs | 5 +++-- test/UnitTest/Components/TableTest.cs | 18 +++++++++++++++--- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 008c937303e..8d1424c12a5 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1408,15 +1408,23 @@ private async Task LoadTableColumnStates() if (state != null) { _tableColumnStateCache = state; + _clientTableColumnStateRestored = true; } } else if (OnLoadTableColumnClientStatus != null) { // 恢复持久化列状态配置 _tableColumnStateCache = await OnLoadTableColumnClientStatus(); + _clientTableColumnStateRestored = true; } } + /// + /// 列状态是否刚从客户端持久化配置恢复 固定列状态不参与持久化 首次回放时以列实例值为准 + /// Whether column states were just restored from client persistence. Fixed state is not persisted + /// + private bool _clientTableColumnStateRestored; + /// /// 列固定状态应用缓存 键为列实例 值为最后一次应用到该实例上的固定状态 用于识别运行时变更 /// Applied fixed state cache keyed by column instance. Used to detect runtime changes @@ -1623,6 +1631,12 @@ private void ResetTableColumns() var item = _tableColumnStates[index]; var col = columnMap[item.Name]; + // 固定列状态不参与客户端持久化 恢复的状态首次回放时以列实例代码声明值为准 + if (_clientTableColumnStateRestored) + { + item.Fixed = col.Fixed; + } + // 将列状态回放到列实例上 解决持久化恢复与动态固定列问题 col.Fixed = item.Fixed; col.Width = item.Width ?? col.Width; @@ -1635,6 +1649,8 @@ private void ResetTableColumns() } } } + + _clientTableColumnStateRestored = false; } private TableColumnState CreateTableColumnState(ITableColumn col) => new TableColumnState() diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.js b/src/BootstrapBlazor/Components/Table/Table.razor.js index 535ca2a9c53..65cf3aa7b08 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.js +++ b/src/BootstrapBlazor/Components/Table/Table.razor.js @@ -1040,8 +1040,7 @@ const getColumnStateObject = table => { return { name: col.name, width: getColumnWidth(col, table.columns), - visible: col.visible, - fixed: col.fixed + visible: col.visible } }), table: getTableWidth(table.tables[0]) @@ -1053,8 +1052,7 @@ const getColumnStateObject = table => { return { name: getColumnName(col), width: getResizableColumnWidth(col), - visible: true, - fixed: getColumnHeader(col).classList.contains('fixed') + visible: true } }), table: getTableWidth(table.tables[0]) diff --git a/src/BootstrapBlazor/Components/Table/TableColumnState.cs b/src/BootstrapBlazor/Components/Table/TableColumnState.cs index 3a2e181395a..246da93676a 100644 --- a/src/BootstrapBlazor/Components/Table/TableColumnState.cs +++ b/src/BootstrapBlazor/Components/Table/TableColumnState.cs @@ -40,8 +40,9 @@ public class TableColumnState public int? Width { get; set; } /// - /// 获得/设置 列固定 - /// Gets or sets column fixed + /// 获得/设置 列固定 固定列状态由代码管理 不参与客户端持久化 + /// Gets or sets column fixed. Fixed state is code-managed and excluded from client persistence /// + [JsonIgnore] public bool Fixed { get; set; } } diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 36284b3e160..c3703bb0afb 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9151,7 +9151,7 @@ await cut.InvokeAsync(() => [Fact] public async Task DynamicFixedColumn_ClientTableName_Ok() { - // 持久化状态中 Name 列为固定列 + // 固定列状态由代码管理不参与持久化 持久化状态中的 Fixed 值不恢复到列实例上 var state = new TableColumnClientStatus(); state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100, Fixed = true }); state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120, Fixed = false }); @@ -9183,12 +9183,24 @@ public async Task DynamicFixedColumn_ClientTableName_Ok() var table = cut.FindComponent>(); - // 持久化状态恢复 Name 列固定 + // 持久化状态中的 Fixed 不恢复 列实例保持代码声明值 宽度正常恢复 var columns = cut.FindAll("thead th"); + Assert.DoesNotContain("fixed", columns[0].ClassName ?? ""); + Assert.False(table.Instance.Columns[0].Fixed); + Assert.Equal(100, table.Instance.Columns[0].Width); + Assert.Equal(120, table.Instance.Columns[1].Width); + + // 运行时切换固定列仍正常工作 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + + columns = cut.FindAll("thead th"); Assert.Contains("fixed", columns[0].ClassName); Assert.True(table.Instance.Columns[0].Fixed); - // 运行时取消固定 持久化恢复的状态不覆盖运行时变更 await cut.InvokeAsync(() => { table.Instance.Columns[0].Fixed = false; From 2affaf3ae42caa773f6b6de55f66fe78a31dd332 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 16 Jul 2026 18:06:16 +0800 Subject: [PATCH 38/44] refactor(Table): remove Fixed from TableColumnState link #8094 TableColumnState.Fixed was introduced on this branch and after excluding it from client persistence its only remaining purpose was carrying the runtime fixed state across column rebuilds. Replace it with an internal name-keyed cache that records only columns whose fixed state actually changed at runtime, so replay no longer touches columns managed by code (markup declarations, OnColumnCreating) and the restore workaround for persisted states becomes unnecessary. Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.cs | 30 ++++++++----------- .../Components/Table/TableColumnState.cs | 7 ----- test/UnitTest/Components/TableTest.cs | 12 ++++---- 3 files changed, 19 insertions(+), 30 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 8d1424c12a5..0d2aa7ec118 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1408,22 +1408,20 @@ private async Task LoadTableColumnStates() if (state != null) { _tableColumnStateCache = state; - _clientTableColumnStateRestored = true; } } else if (OnLoadTableColumnClientStatus != null) { // 恢复持久化列状态配置 _tableColumnStateCache = await OnLoadTableColumnClientStatus(); - _clientTableColumnStateRestored = true; } } /// - /// 列状态是否刚从客户端持久化配置恢复 固定列状态不参与持久化 首次回放时以列实例值为准 - /// Whether column states were just restored from client persistence. Fixed state is not persisted + /// 运行时动态固定列缓存 键为列名称 值为运行时变更后的固定状态 列重建时回放 未变更过的列保持代码声明值 + /// Runtime dynamic fixed state cache keyed by column name. Replayed on column rebuild /// - private bool _clientTableColumnStateRestored; + private readonly Dictionary _dynamicFixedColumnCache = []; /// /// 列固定状态应用缓存 键为列实例 值为最后一次应用到该实例上的固定状态 用于识别运行时变更 @@ -1477,9 +1475,13 @@ private void SyncColumnsFixedState() private void ApplyColumnFixedState(ITableColumn col, TableColumnState state) { + // 仅记录运行时变更过的固定状态 未变更过的列保持代码声明值 避免列重建回放时覆盖代码赋值 + if (_appliedFixedColumnCache.TryGetValue(col, out var applied) && applied != col.Fixed) + { + _dynamicFixedColumnCache[state.Name] = col.Fixed; + } _appliedFixedColumnCache[col] = col.Fixed; - state.Fixed = col.Fixed; state.Width = col.Width; } @@ -1631,14 +1633,11 @@ private void ResetTableColumns() var item = _tableColumnStates[index]; var col = columnMap[item.Name]; - // 固定列状态不参与客户端持久化 恢复的状态首次回放时以列实例代码声明值为准 - if (_clientTableColumnStateRestored) + // 将列状态回放到列实例上 运行时动态变更过的固定状态回放 未变更过的列保持代码声明值 + if (_dynamicFixedColumnCache.TryGetValue(item.Name, out var fixedState)) { - item.Fixed = col.Fixed; + col.Fixed = fixedState; } - - // 将列状态回放到列实例上 解决持久化恢复与动态固定列问题 - col.Fixed = item.Fixed; col.Width = item.Width ?? col.Width; _appliedFixedColumnCache[col] = col.Fixed; @@ -1649,8 +1648,6 @@ private void ResetTableColumns() } } } - - _clientTableColumnStateRestored = false; } private TableColumnState CreateTableColumnState(ITableColumn col) => new TableColumnState() @@ -1658,8 +1655,7 @@ private void ResetTableColumns() DisplayName = col.GetDisplayName(), Name = col.GetFieldName(), Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width, - Visible = col.GetVisible(_screenSize), - Fixed = col.Fixed + Visible = col.GetVisible(_screenSize) }; private async Task OnTableRenderAsync(bool firstRender) @@ -2179,7 +2175,7 @@ public async Task UpdateTableColumnClientStatus() var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); if (columnState != null) { - fixedChanged |= columnState.Fixed != item.Fixed; + fixedChanged |= _appliedFixedColumnCache.TryGetValue(item, out var applied) && applied != item.Fixed; ApplyColumnFixedState(item, columnState); } } diff --git a/src/BootstrapBlazor/Components/Table/TableColumnState.cs b/src/BootstrapBlazor/Components/Table/TableColumnState.cs index 246da93676a..1aad8e442d8 100644 --- a/src/BootstrapBlazor/Components/Table/TableColumnState.cs +++ b/src/BootstrapBlazor/Components/Table/TableColumnState.cs @@ -38,11 +38,4 @@ public class TableColumnState /// Gets or sets column width /// public int? Width { get; set; } - - /// - /// 获得/设置 列固定 固定列状态由代码管理 不参与客户端持久化 - /// Gets or sets column fixed. Fixed state is code-managed and excluded from client persistence - /// - [JsonIgnore] - public bool Fixed { get; set; } } diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index c3703bb0afb..17d27cd05d8 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9027,8 +9027,8 @@ public async Task UpdateTableColumnClientStatus_Ok() { var state = new TableColumnClientStatus(); state.TableWidth = 220; - state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Fixed = true }); - state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120, Fixed = false }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120 }); Context.JSInterop.Setup("getColumnStates", "test_update").SetResult(state); var invoker = Context.JSInterop.SetupVoid("updateColumnStates", "test_update"); @@ -9064,7 +9064,7 @@ public async Task UpdateTableColumnClientStatus_Ok() Assert.Contains("style=\"width: 120px;\"", colGroup.ToMarkup()); var status = await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); - Assert.Equal(true, status.Columns[0].Fixed); + Assert.True(table.Instance.Columns[0].Fixed); Assert.Equal(state.Columns.Count, status.Columns.Count); table = cut.FindComponent>(); @@ -9151,10 +9151,10 @@ await cut.InvokeAsync(() => [Fact] public async Task DynamicFixedColumn_ClientTableName_Ok() { - // 固定列状态由代码管理不参与持久化 持久化状态中的 Fixed 值不恢复到列实例上 + // 固定列状态由代码管理不参与持久化 持久化状态只包含宽度可见性等用户个性化配置 var state = new TableColumnClientStatus(); - state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100, Fixed = true }); - state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120, Fixed = false }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100 }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120 }); Context.JSInterop.Setup("getColumnStates", "test_dynamic_fixed").SetResult(state); From 0baca1e219fed0e8993f7e5d69c3c3233ffcf7d6 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Thu, 16 Jul 2026 18:55:36 +0800 Subject: [PATCH 39/44] refactor(Table): simplify fixed column state sync and fix width restore timing link #8094 - SyncColumnsFixedState now only detects and records runtime changes and returns whether anything changed; ApplyColumnFixedState removed, its duplicated detection and state lookup are gone - UpdateTableColumnClientStatus reuses the same detection and unifies its measure/sync conditions; measuring also covers fixed columns with explicit width plus other width-less columns which previously skipped width backfill - run SyncColumnsWidthState after columns are rebuilt and replayed so the fixed state it inspects is final; previously toggling fixed off during the same render that OnColumnCreating unfixes columns left overridden widths applied forever, also sync overridden width back to the column instance since replay has already run Co-Authored-By: Claude Fable 5 --- .../Components/Table/Table.razor.cs | 58 +++++++++---------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 0d2aa7ec118..4c474437a2d 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1337,7 +1337,7 @@ private List GetTableColumns() private async Task BuildTableColumnsAsync() { // 重建列信息前先同步运行时变更的固定列状态,支持动态设置固定列 - SyncColumnsFixedState(); + var fixedChanged = SyncColumnsFixedState(); // 构建列信息 var cols = GetTableColumns(); @@ -1354,6 +1354,12 @@ private async Task BuildTableColumnsAsync() // 加载客户端持久化列状态 ResetTableColumns(); + + // 列重建回放后固定状态为本轮最终值 此时再同步列宽度 + if (fixedChanged) + { + SyncColumnsWidthState(); + } } private void EnsureTemplateColumnFieldNames() @@ -1450,39 +1456,27 @@ private async Task RefreshClientColumnWidthsAsync() } } - private void SyncColumnsFixedState() + /// + /// 同步运行时变更的固定列状态 仅记录应用过固定状态的列实例 未变更过的列保持代码声明值 + /// Sync runtime fixed state changes. Only columns whose applied state exists are tracked + /// + /// + /// 是否存在固定状态变更 + /// Whether any fixed state changed + /// + private bool SyncColumnsFixedState() { var changed = false; foreach (var col in Columns) { - // 仅同步应用过固定状态的列实例,避免覆盖持久化恢复的状态 if (_appliedFixedColumnCache.TryGetValue(col, out var applied) && applied != col.Fixed) { - var state = _tableColumnStates.Find(i => i.Name == col.GetFieldName()); - if (state != null) - { - ApplyColumnFixedState(col, state); - changed = true; - } + _appliedFixedColumnCache[col] = col.Fixed; + _dynamicFixedColumnCache[col.GetFieldName()] = col.Fixed; + changed = true; } } - - if (changed) - { - SyncColumnsWidthState(); - } - } - - private void ApplyColumnFixedState(ITableColumn col, TableColumnState state) - { - // 仅记录运行时变更过的固定状态 未变更过的列保持代码声明值 避免列重建回放时覆盖代码赋值 - if (_appliedFixedColumnCache.TryGetValue(col, out var applied) && applied != col.Fixed) - { - _dynamicFixedColumnCache[state.Name] = col.Fixed; - } - _appliedFixedColumnCache[col] = col.Fixed; - - state.Width = col.Width; + return changed; } private void SyncColumnsWidthState() @@ -1507,6 +1501,7 @@ private void SyncColumnsWidthState() var original = _autoFixedColumnWidthCache.TryGetValue(state.Name, out var exist) ? exist.Original : col.Width; _autoFixedColumnWidthCache[state.Name] = (width, original); state.Width = width; + col.Width = width; } } else if (col.Fixed && !col.Width.HasValue && state.Width == null) @@ -1514,6 +1509,7 @@ private void SyncColumnsWidthState() // 无实测宽度时为固定列兜底默认宽度 _autoFixedColumnWidthCache[state.Name] = (DefaultFixedColumnWidth, null); state.Width = DefaultFixedColumnWidth; + col.Width = DefaultFixedColumnWidth; } } } @@ -2163,25 +2159,25 @@ public async Task UpdateTableColumnClientStatus() { if (Columns.Count != 0) { - if (Columns.Exists(i => i.Fixed) && Columns.Exists(i => !i.Width.HasValue)) + // 存在固定列且有列缺少宽度时刷新客户端实测列宽快照 + var needMeasure = Columns.Exists(i => i.Fixed) && Columns.Exists(i => !i.Width.HasValue); + if (needMeasure) { await RefreshClientColumnWidthsAsync(); } // 用户在外面变更了列状态后,为避免用户变更状态丢失,须将变更后的状态同步到缓存中 - var fixedChanged = false; foreach (var item in Columns) { var columnState = _tableColumnStates.Find(x => x.Name == item.GetFieldName()); if (columnState != null) { - fixedChanged |= _appliedFixedColumnCache.TryGetValue(item, out var applied) && applied != item.Fixed; - ApplyColumnFixedState(item, columnState); + columnState.Width = item.Width; } } // 固定状态变化或固定列缺少宽度时同步列宽度 其余场景跳过防止手动调整的列宽被实测快照覆盖 - if (fixedChanged || Columns.Exists(i => i.Fixed && !i.Width.HasValue)) + if (SyncColumnsFixedState() || needMeasure) { SyncColumnsWidthState(); } From ab313ea4425a04fcd8a0268cfd5877cfb29142c6 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Tue, 21 Jul 2026 00:14:36 +0800 Subject: [PATCH 40/44] =?UTF-8?q?fix(table):=20=E5=8D=A0=E4=BD=8D=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E5=AE=BD=E4=B8=8D=E5=9B=9E=E7=81=8C=E5=88=97=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E4=BB=A5=E6=81=A2=E5=A4=8D=E5=AE=9E=E6=B5=8B=E5=AE=BD?= =?UTF-8?q?=E5=BA=A6=E6=9C=BA=E5=88=B6=20(PR=20#8110,=20Issue=20#8094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改动:Table.razor.cs ResetTableColumns 回放处增加占位判别,列实例无宽且列状态宽等于 #8241 兜底值(DefaultFixedColumnWidth 或 ColumnMinWidth)时跳过 col.Width = item.Width - 为什么:#8241 每轮重建给无宽列状态回填 64/200 占位宽,回放将其灌进 col.Width 后,getColumnWidths 实测快照守卫(!Width.HasValue)永假、IsFixedColumnLayout 误判启用 table-layout-fixed,固定列时全表被压成 64px 塌缩、横向滚动消失(实测复现) - 边界:判别用常量值而非 GetDefaultColumnStateWidth 动态求值(固定状态变化会把占位值误判为真实宽);真实持久化宽/显式宽/实测宽仍正常回放;占位宽保留在列状态中供 JS 布局,不改变 #8241 既有语义 - 验证:dotnet test --filter DynamicFixedColumn 等 10 项全过;TableTest 320/320;真实浏览器(作者 demo + Edge)固定不塌缩、1920/800 视口 sticky 偏移精确、取消固定还原 - 影响面:既有用例零删改;列状态持久化行为不变 Co-Authored-By: Kimi --- src/BootstrapBlazor/Components/Table/Table.razor.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index de2084e55d7..11d51f1982c 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1642,7 +1642,15 @@ private void ResetTableColumns() { col.Fixed = fixedState; } - col.Width = item.Width ?? col.Width; + + // 无宽列的占位回填宽(NormalizeVisibleColumnWidth 兜底值 DefaultFixedColumnWidth 或 ColumnMinWidth)不回灌列实例 + // 占位宽仅保留在列状态中供客户端布局使用 列实例保持无宽 + // 以便实测宽度快照采集、固定布局判定、取消固定还原等逻辑按真实宽度工作 + // 注意占位值与列固定状态无关 此处不能用 GetDefaultColumnStateWidth 动态求值 固定状态变化会导致占位宽漏判 + if (col.Width.HasValue || (item.Width != DefaultFixedColumnWidth && item.Width != (ColumnMinWidth ?? Options.CurrentValue.TableSettings.ColumnMinWidth))) + { + col.Width = item.Width ?? col.Width; + } _appliedFixedColumnCache[col] = col.Fixed; if (item.Visible) From 748e4547910e98dc26a7ee2e871b409ee5cd64a0 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Tue, 21 Jul 2026 00:16:20 +0800 Subject: [PATCH 41/44] =?UTF-8?q?fix(table):=20ClearTableColumnClientStatu?= =?UTF-8?q?s=20=E5=85=88=E8=BF=98=E5=8E=9F=E8=A6=86=E7=9B=96=E5=AE=BD?= =?UTF-8?q?=E5=86=8D=E6=B8=85=E7=8A=B6=E6=80=81=20(PR=20#8110,=20Issue=20#?= =?UTF-8?q?8094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改动:SyncColumnsWidthState else 分支还原逻辑提取为 RestoreAutoFixedColumnWidths()(原样搬移无行为变化);ClearTableColumnClientStatus 改为在 _tableColumnStateCache.Clear() 之前调用 RestoreAutoFixedColumnWidths(),替换原 _autoFixedColumnWidthCache.Clear() - 为什么:原实现固定列期间调用 ClearTableColumnClientStatus 只清空 _autoFixedColumnWidthCache 不还原;且 _tableColumnStates 就是 _tableColumnStateCache.Columns(Table.razor.Toolbar.cs:547),先清空状态列表后还原目标必然丢失,自动回填的实测宽永久残留在列实例上,取消固定也无法还原 - 边界:还原条件 state.Width == item.Applied 保持不变,用户拖拽调整过的列宽仍按原逻辑保留不清除 - 验证:新增 ClearTableColumnClientStatus_RestoreAutoFixedWidth_Ok 由红转绿(修复前亲见失败);TableTest 320/320 全过 - 影响面:仅影响 Clear 调用场景下的宽度还原时机,清 localStorage、清状态缓存、StateHasChanged 等既有行为不变 Co-Authored-By: Kimi --- .../Components/Table/Table.razor.cs | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 11d51f1982c..9e42267b1e5 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1515,22 +1515,28 @@ private void SyncColumnsWidthState() } else { - // 无固定列时还原全部覆盖宽度恢复原始布局 用户拖拽调整过列宽时保留调整值 - foreach (var (name, item) in _autoFixedColumnWidthCache) + // 无固定列时还原全部覆盖宽度恢复原始布局 + RestoreAutoFixedColumnWidths(); + } + } + + private void RestoreAutoFixedColumnWidths() + { + // 还原全部覆盖宽度恢复原始布局 用户拖拽调整过列宽时保留调整值 + foreach (var (name, item) in _autoFixedColumnWidthCache) + { + var state = _tableColumnStates.Find(i => i.Name == name); + if (state != null && state.Width == item.Applied) { - var state = _tableColumnStates.Find(i => i.Name == name); - if (state != null && state.Width == item.Applied) + state.Width = item.Original; + var col = Columns.Find(i => i.GetFieldName() == name); + if (col != null) { - state.Width = item.Original; - var col = Columns.Find(i => i.GetFieldName() == name); - if (col != null) - { - col.Width = item.Original; - } + col.Width = item.Original; } } - _autoFixedColumnWidthCache.Clear(); } + _autoFixedColumnWidthCache.Clear(); } private void ResetTableColumns() @@ -2257,9 +2263,12 @@ public async Task ClearTableColumnClientStatus() await InvokeVoidAsync("clearColumnStates", ClientTableName); } + // 固定列期间清除状态时先还原自动回填的覆盖宽度 避免后续取消固定时宽度残留 + // 注意须在清空前还原 清空后 _tableColumnStates 为空将无法还原 + RestoreAutoFixedColumnWidths(); + // 清除缓存的列状态 _tableColumnStateCache.Clear(); - _autoFixedColumnWidthCache.Clear(); StateHasChanged(); } From 4bb6d42a225397a3ca495675517f727375692639 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Tue, 21 Jul 2026 00:17:14 +0800 Subject: [PATCH 42/44] =?UTF-8?q?test(table):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E5=9B=BA=E5=AE=9A=E5=88=97=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E6=B8=85=E9=99=A4=E7=8A=B6=E6=80=81=E8=BF=98?= =?UTF-8?q?=E5=8E=9F=E7=94=A8=E4=BE=8B=20(PR=20#8110,=20Issue=20#8094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DynamicFixedColumn_IgnoreColumn_Ok:Ignore 列无列状态时 SyncColumnsWidthState 命中 state == null 跳过分支,补齐 PR 原有 2 行未覆盖代码(Table.razor.cs:1493-1494),全项目行覆盖率由 99.99% 回到 100%(34596/34596) - 新增 ClearTableColumnClientStatus_RestoreAutoFixedWidth_Ok:固定期间清除客户端状态后再取消固定,自动回填宽还原为空不残留(红绿验证:修复前先见红) - 修复 UpdateTableColumnClientStatus_Ok 弱断言:删除 if 包裹改为直接断言 fixed 类与 col 宽度,消除 CS8602 编译警告 - 验证:dotnet test test/UnitTest --collect:"XPlat Code Coverage" 全量 2510/2510 通过,line-rate 1.0;回放占位判别条件 8/8 分支全覆盖 - 影响面:仅新增/修正测试,不改动任何既有用例断言 Co-Authored-By: Kimi --- test/UnitTest/Components/TableTest.cs | 108 ++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 931528a9612..1f5be72e488 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9181,11 +9181,8 @@ public async Task UpdateTableColumnClientStatus_Ok() var columns = cut.FindAll("th"); colGroup = table.Find("colgroup"); Assert.Contains("style=\"width: 200px;\"", colGroup.ToMarkup()); - if (columns[0].ClassName.Contains("fixed")) - { - var fixedWidth = cut.FindAll("col")[0].OuterHtml.Contains("width: 200px"); - Assert.Equal("fixedWidth:True", $"fixedWidth:{fixedWidth}"); - } + Assert.Contains("fixed", columns[0].ClassName ?? ""); + Assert.Contains("width: 200px", cut.FindAll("col")[0].OuterHtml); } [Fact] @@ -9659,6 +9656,53 @@ await cut.InvokeAsync(() => Assert.Equal(150, table.Instance.Columns[0].Width); } + [Fact] + public async Task DynamicFixedColumn_IgnoreColumn_Ok() + { + // 固定列时被忽略列没有列状态 同步列宽时跳过该列不影响固定列 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 } + }); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.AddAttribute(6, "Ignore", true); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 运行时固定第一列 被忽略的 Address 列无列状态 同步宽度时跳过 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + + var columns = cut.FindAll("thead th"); + Assert.Contains("fixed", columns[0].ClassName); + Assert.Contains("left: 0px;", columns[0].OuterHtml); + Assert.Equal(120, table.Instance.Columns[0].Width); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() { @@ -9708,6 +9752,60 @@ public async Task ClearTableColumnClientStatus_Ok() Assert.Contains("style=\"width: 80px;\"", colGroup.ToMarkup()); } + [Fact] + public async Task ClearTableColumnClientStatus_RestoreAutoFixedWidth_Ok() + { + // 固定列期间清除了客户端状态 自动回填的实测宽度应同步还原 后续取消固定时列宽不残留 + Context.JSInterop.Setup>("getColumnWidths", _ => true).SetResult(new Dictionary + { + { nameof(Foo.Name), 120 }, + { nameof(Foo.Address), 180 } + }); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 固定第一列 使用客户端实测宽度 120 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = true; + }); + table.Render(); + Assert.Equal(120, table.Instance.Columns[0].Width); + + // 固定期间清除客户端状态 + await cut.InvokeAsync(() => table.Instance.ClearTableColumnClientStatus()); + + // 取消固定后 自动回填的宽度应还原为空 不残留在列实例上 + await cut.InvokeAsync(() => + { + table.Instance.Columns[0].Fixed = false; + }); + table.Render(); + Assert.False(table.Instance.Columns[0].Width.HasValue); + } + [Fact] public void ColumnStates_PartialMatch_KeepColumnOrder_Ok() { From 00a8bce8a10766094578b92773422c10e6227877 Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Tue, 21 Jul 2026 11:27:16 +0800 Subject: [PATCH 43/44] =?UTF-8?q?fix(table):=20=E5=8D=A0=E4=BD=8D=E5=AE=BD?= =?UTF-8?q?=E5=88=A4=E5=88=AB=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E4=BB=A5=E6=B6=88=E9=99=A4=E7=A2=B0=E6=92=9E=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=20(PR=20#8110,=20Issue=20#8094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 改动:新增实例级 HashSet _placeholderWidthColumnNames(StringComparer.Ordinal)记录占位身份;NormalizeVisibleColumnWidth 回填兜底宽、CreateTableColumnState 对固定无宽列填默认值时打标记;ResetTableColumns 回放判别由"按值比较(64/200)"改为"查标记",真实宽度写入点同步移除标记 - 为什么:按值判别把"等于占位值的宽"一律视为占位,而拖拽下限 5px 与持久化可合法产生恰为 64/200 的真实宽——R1:持久化 200 被误判跳过并经 UpdateTableColumnClientStatus 写回 null 永久退化为 64;R2:固定列持久化 64 被误判后 fixed 布局退化、sticky 偏移按 200 回退最多错位 136px - 清除点(防标记残留误伤后到真实宽):实测宽覆盖、固定兜底宽应用、RestoreAutoFixedColumnWidths 还原、ResetVisibleColumns 与 UpdateTableColumnClientStatus 宿主显式设宽、UpdateTableColumnState 拖拽回传、CreateTableColumnState 真实宽重建、列状态移除、ClearTableColumnClientStatus 清空 - 持久化防泄漏:OnTableColumnClientStatusChanged(拖拽/调整两回调)改发 GetTableColumnClientStatusForPersist() 剥离副本,占位列宽以 null 输出,防止占位值经宿主存档在下一会话被误认作真实宽 - 边界:占位宽仍保留在列状态中供客户端布局(#8241 不变量不变);UpdateTableColumnClientStatus 返回链路无泄漏(实例宽 null 先行同步)故未改动;JS 零改动、公开 API 零变更 - 验证:TDD 红绿闭环,4 个新用例修复前亲见失败(200→null、64→null、回调泄漏 64)、修复后通过;TableTest 325/325;全量 2514/2514;行覆盖 34636/34636=100%,branch-rate 0.9623 不低于基线 - 影响面:既有用例零删改;回调负载语义有一处变化(宿主看到的占位宽由 64/200 变为 null,该假值系 #8241 一天前才引入;回调对象由活引用改为剥离副本) - 残余问题(如实声明):①隐藏固定列的占位 200 仍可经 JS getColumnStateObject 的 col.width 回退落盘,下一会话回放到实例——数值恰等于 sticky 偏移回退值 200,无视觉差异,根除需改 JS 回退语义且影响隐藏列保留真实宽,留作已知限制;②UpdateTableColumnClientStatus 对占位跳过列仍会写回 null(下轮重建重新回填,结果正确),系 PR 原有行为未扩大;③已推送的 4bb6d42a2 提交信息"TableTest 320/320"与实际 321 不符,历史已推送不改写,拟在 PR 评论更正 Co-Authored-By: Kimi --- .../Components/Table/Table.razor.cs | 100 +++++++++++++++--- 1 file changed, 88 insertions(+), 12 deletions(-) diff --git a/src/BootstrapBlazor/Components/Table/Table.razor.cs b/src/BootstrapBlazor/Components/Table/Table.razor.cs index 9e42267b1e5..fffa2a45320 100644 --- a/src/BootstrapBlazor/Components/Table/Table.razor.cs +++ b/src/BootstrapBlazor/Components/Table/Table.razor.cs @@ -1441,6 +1441,14 @@ private async Task LoadTableColumnStates() /// private readonly Dictionary _autoFixedColumnWidthCache = []; + /// + /// 占位宽度列名集合 由 NormalizeVisibleColumnWidth/CreateTableColumnState 回填兜底宽度时标记 真实宽度写入时移除 + /// 列状态回放到列实例时据此区分占位宽与真实宽 避免按值比较误伤与占位值相等的真实宽度 + /// Names of columns whose state width is a placeholder backfill. Marked when backfilled, removed when a real width is written. + /// Used during column state replay to distinguish placeholders from real widths without value comparison. + /// + private readonly HashSet _placeholderWidthColumnNames = new(StringComparer.Ordinal); + /// /// 客户端实际渲染列宽快照 渲染结束后由脚本测量更新 未显式设置宽度列的实际宽度由浏览器布局决定 服务器端无法得知 /// Snapshot of actual rendered column widths measured by script after each render @@ -1502,6 +1510,9 @@ private void SyncColumnsWidthState() _autoFixedColumnWidthCache[state.Name] = (width, original); state.Width = width; col.Width = width; + + // 实测宽度为真实宽度 移除占位标记 + _placeholderWidthColumnNames.Remove(state.Name); } } else if (col.Fixed && !col.Width.HasValue && state.Width == null) @@ -1510,6 +1521,9 @@ private void SyncColumnsWidthState() _autoFixedColumnWidthCache[state.Name] = (DefaultFixedColumnWidth, null); state.Width = DefaultFixedColumnWidth; col.Width = DefaultFixedColumnWidth; + + // 兜底宽度已应用到列实例 视为真实宽度 移除占位标记 + _placeholderWidthColumnNames.Remove(state.Name); } } } @@ -1534,6 +1548,9 @@ private void RestoreAutoFixedColumnWidths() { col.Width = item.Original; } + + // 还原后宽度身份重置 真实值已由上方写回 空值由下轮重建的占位回填重新标记 + _placeholderWidthColumnNames.Remove(name); } } _autoFixedColumnWidthCache.Clear(); @@ -1601,6 +1618,8 @@ private void ResetTableColumns() } else { + // 列已不存在 同步移除其占位标记防止残留 + _placeholderWidthColumnNames.Remove(item.Name); _tableColumnStates.RemoveAt(i); } } @@ -1649,11 +1668,11 @@ private void ResetTableColumns() col.Fixed = fixedState; } - // 无宽列的占位回填宽(NormalizeVisibleColumnWidth 兜底值 DefaultFixedColumnWidth 或 ColumnMinWidth)不回灌列实例 - // 占位宽仅保留在列状态中供客户端布局使用 列实例保持无宽 + // 占位回填宽不回灌列实例 占位宽仅保留在列状态中供客户端布局使用 列实例保持无宽 // 以便实测宽度快照采集、固定布局判定、取消固定还原等逻辑按真实宽度工作 - // 注意占位值与列固定状态无关 此处不能用 GetDefaultColumnStateWidth 动态求值 固定状态变化会导致占位宽漏判 - if (col.Width.HasValue || (item.Width != DefaultFixedColumnWidth && item.Width != (ColumnMinWidth ?? Options.CurrentValue.TableSettings.ColumnMinWidth))) + // 占位身份由 NormalizeVisibleColumnWidth/CreateTableColumnState 回填时显式标记 真实宽度写入时移除 + // 不做按值比较 与占位值相等的真实宽度(如用户拖拽至 ColumnMinWidth)不受误伤 + if (col.Width.HasValue || !_placeholderWidthColumnNames.Contains(item.Name)) { col.Width = item.Width ?? col.Width; } @@ -1668,13 +1687,29 @@ private void ResetTableColumns() } } - private TableColumnState CreateTableColumnState(ITableColumn col) => new TableColumnState() + private TableColumnState CreateTableColumnState(ITableColumn col) { - DisplayName = col.GetDisplayName(), - Name = col.GetFieldName(), - Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width, - Visible = col.GetVisible(_screenSize) - }; + var state = new TableColumnState() + { + DisplayName = col.GetDisplayName(), + Name = col.GetFieldName(), + Width = col.Fixed && !col.Width.HasValue ? DefaultFixedColumnWidth : col.Width, + Visible = col.GetVisible(_screenSize) + }; + + if (col.Fixed && !col.Width.HasValue) + { + // 固定列无显式宽度时状态宽为占位回填值 标记占位身份 + _placeholderWidthColumnNames.Add(state.Name); + } + else + { + // 同名状态重建等场景下清除可能残留的占位标记 + _placeholderWidthColumnNames.Remove(state.Name); + } + + return state; + } private bool NormalizeVisibleColumnWidth(TableColumnState item, ITableColumn col) { @@ -1684,6 +1719,9 @@ private bool NormalizeVisibleColumnWidth(TableColumnState item, ITableColumn col } item.Width = GetDefaultColumnStateWidth(col); + + // 回填的兜底宽仅为客户端布局占位 标记占位身份 不代表列真实宽度 + _placeholderWidthColumnNames.Add(item.Name); return true; } @@ -1802,6 +1840,9 @@ public void ResetVisibleColumns(IReadOnlyCollection columns) if (col.Width.HasValue) { column.Width = col.Width; + + // 宿主显式设置的宽度为真实宽度 移除占位标记 + _placeholderWidthColumnNames.Remove(col.Name); } } } @@ -2234,6 +2275,12 @@ public async Task UpdateTableColumnClientStatus() if (columnState != null) { columnState.Width = item.Width; + + // 宿主变更了显式宽度时视为真实宽度 移除占位标记 + if (item.Width.HasValue) + { + _placeholderWidthColumnNames.Remove(columnState.Name); + } } } @@ -2270,6 +2317,9 @@ public async Task ClearTableColumnClientStatus() // 清除缓存的列状态 _tableColumnStateCache.Clear(); + // 列状态已全部清除 占位标记同步清空 + _placeholderWidthColumnNames.Clear(); + StateHasChanged(); } @@ -2308,7 +2358,7 @@ public async Task DragColumnCallback(int originIndex, int currentIndex) if (OnTableColumnClientStatusChanged != null) { - await OnTableColumnClientStatusChanged(firstColumn.Name, _tableColumnStateCache); + await OnTableColumnClientStatusChanged(firstColumn.Name, GetTableColumnClientStatusForPersist()); } _resetColumns = true; @@ -2331,12 +2381,32 @@ public async Task ResizeColumnCallback(string name, TableColumnClientStatus colu // 触发回调 if (OnTableColumnClientStatusChanged != null) { - await OnTableColumnClientStatusChanged(name, _tableColumnStateCache); + await OnTableColumnClientStatusChanged(name, GetTableColumnClientStatusForPersist()); } StateHasChanged(); } + /// + /// 获得供宿主持久化的列状态副本 占位回填宽度以 null 输出 防止占位值被宿主存档后下一会话误认作真实宽度 + /// Get a copy of column states for host persistence. Placeholder widths are emitted as null so they are not mistaken for real widths in the next session. + /// + private TableColumnClientStatus GetTableColumnClientStatusForPersist() + { + var status = new TableColumnClientStatus() { TableWidth = _tableColumnStateCache.TableWidth }; + foreach (var item in _tableColumnStates) + { + status.Columns.Add(new TableColumnState() + { + Name = item.Name, + DisplayName = item.DisplayName, + Visible = item.Visible, + Width = _placeholderWidthColumnNames.Contains(item.Name) ? null : item.Width + }); + } + return status; + } + /// /// 列宽自适应回调方法 由 JavaScript 脚本调用 /// Auto Fit Column Width Callback called by JavaScript @@ -2355,6 +2425,12 @@ private void UpdateTableColumnState(TableColumnClientStatus columnState) if (colState != null) { item.Width = colState.Width; + + // 客户端回传的拖拽/实测宽度为真实宽度 移除占位标记 + if (colState.Width.HasValue) + { + _placeholderWidthColumnNames.Remove(item.Name); + } } } _tableColumnStateCache.TableWidth = columnState.TableWidth; From 682fe1da5ffae9bf284253cb9ad50152b26fcfcd Mon Sep 17 00:00:00 2001 From: momiji-gz302ea <2648863351@qq.com> Date: Tue, 21 Jul 2026 11:27:50 +0800 Subject: [PATCH 44/44] =?UTF-8?q?test(table):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E5=AE=BD=E7=A2=B0=E6=92=9E=E4=B8=8E=E6=B3=84?= =?UTF-8?q?=E6=BC=8F=E7=9A=84=E5=9B=9E=E5=BD=92=E7=94=A8=E4=BE=8B=20(PR=20?= =?UTF-8?q?#8110,=20Issue=20#8094)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DynamicFixedColumn_PersistedDefaultWidthCollision_Ok:持久化宽恰为 DefaultFixedColumnWidth(200) 时按真实宽回放,且 UpdateTableColumnClientStatus 后不擦除(R1 回归) - 新增 DynamicFixedColumn_PersistedMinWidthCollision_Ok:固定列持久化宽恰为 ColumnMinWidth(64) 时按真实宽回放,fixed 布局生效(R2 回归) - 新增 DynamicFixedColumn_PlaceholderWidthNotLeakToHost_Ok / NotLeakOnDrag_Ok:OnTableColumnClientStatusChanged 回调负载中占位列宽输出为 null,防占位值经宿主持久化跨会话误认(H1 回归) - 验证:4 个用例先行红(断言逐一核对为预期失败原因),配合源码修复后转绿;TableTest 325/325(既有 321 条零删改);全量 2514/2514,行覆盖 100% - 影响面:仅新增测试,不改动任何既有用例断言 Co-Authored-By: Kimi --- test/UnitTest/Components/TableTest.cs | 176 ++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/test/UnitTest/Components/TableTest.cs b/test/UnitTest/Components/TableTest.cs index 1f5be72e488..ae1264dff23 100644 --- a/test/UnitTest/Components/TableTest.cs +++ b/test/UnitTest/Components/TableTest.cs @@ -9703,6 +9703,182 @@ await cut.InvokeAsync(() => Assert.Equal(120, table.Instance.Columns[0].Width); } + [Fact] + public async Task DynamicFixedColumn_PersistedDefaultWidthCollision_Ok() + { + // 持久化宽度恰与 DefaultFixedColumnWidth(200) 相同 该宽度是真实用户配置而非占位回填 应正常回放到列实例 + var state = new TableColumnClientStatus(); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 200 }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true }); + + Context.JSInterop.Setup("getColumnStates", "test_width_collision_default").SetResult(state); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_width_collision_default"); + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 真实持久化宽度 200 回放到列实例 + Assert.Equal(200, table.Instance.Columns[0].Width); + + // 同步客户端状态后持久化宽度不被擦除 + var status = await cut.InvokeAsync(() => table.Instance.UpdateTableColumnClientStatus()); + Assert.Equal(200, status.Columns.Find(i => i.Name == nameof(Foo.Name))?.Width); + } + + [Fact] + public async Task DynamicFixedColumn_PersistedMinWidthCollision_Ok() + { + // 固定列持久化宽度恰与 ColumnMinWidth(64) 相同(用户曾拖拽至最小宽) 该宽度是真实用户配置 + // 应回放到列实例 保证 fixed 布局生效 sticky 偏移精确 + var state = new TableColumnClientStatus(); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 64 }); + state.Columns.Add(new TableColumnState() { Name = nameof(Foo.Address), Visible = true, Width = 120 }); + + Context.JSInterop.Setup("getColumnStates", "test_width_collision_min").SetResult(state); + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.ClientTableName, "test_width_collision_min"); + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.AddAttribute(3, "Fixed", true); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.AddAttribute(6, "Width", 120); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 真实持久化宽度 64 回放到列实例 fixed 布局生效 + Assert.Equal(64, table.Instance.Columns[0].Width); + Assert.Contains("table-layout-fixed", cut.Markup); + } + + [Fact] + public async Task DynamicFixedColumn_PlaceholderWidthNotLeakToHost_Ok() + { + // 占位回填宽仅服务客户端布局 不应经 OnTableColumnClientStatusChanged 泄漏给宿主持久化 + // 防止占位值被宿主存档后下一会话误认作真实宽度 + TableColumnClientStatus? captured = null; + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.AllowResizing, true); + pb.Add(a => a.OnTableColumnClientStatusChanged, (string name, TableColumnClientStatus status) => + { + captured = status; + return Task.CompletedTask; + }); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 模拟客户端拖拽 Name 列宽后回传列状态 Address 列仍为占位回填宽 + var dragState = new TableColumnClientStatus(); + dragState.Columns.Add(new TableColumnState() { Name = nameof(Foo.Name), Visible = true, Width = 100 }); + await cut.InvokeAsync(() => table.Instance.ResizeColumnCallback(nameof(Foo.Name), dragState)); + + Assert.NotNull(captured); + Assert.Equal(100, captured.Columns.Find(i => i.Name == nameof(Foo.Name))?.Width); + Assert.Null(captured.Columns.Find(i => i.Name == nameof(Foo.Address))?.Width); + } + + [Fact] + public async Task DynamicFixedColumn_PlaceholderWidthNotLeakOnDrag_Ok() + { + // 拖拽列顺序触发宿主持久化回调时 占位回填宽同样不泄漏 + TableColumnClientStatus? captured = null; + + var localizer = Context.Services.GetRequiredService>(); + var cut = Context.Render(pb => + { + pb.AddChildContent>(pb => + { + pb.Add(a => a.RenderMode, TableRenderMode.Table); + pb.Add(a => a.Items, Foo.GenerateFoo(localizer, 2)); + pb.Add(a => a.AllowDragColumn, true); + pb.Add(a => a.OnTableColumnClientStatusChanged, (string name, TableColumnClientStatus status) => + { + captured = status; + return Task.CompletedTask; + }); + pb.Add(a => a.TableColumns, foo => builder => + { + builder.OpenComponent>(0); + builder.AddAttribute(1, "Field", foo.Name); + builder.AddAttribute(2, "FieldExpression", Utility.GenerateValueExpression(foo, "Name", typeof(string))); + builder.CloseComponent(); + + builder.OpenComponent>(3); + builder.AddAttribute(4, "Field", foo.Address); + builder.AddAttribute(5, "FieldExpression", Utility.GenerateValueExpression(foo, nameof(foo.Address), typeof(string))); + builder.CloseComponent(); + }); + }); + }); + + var table = cut.FindComponent>(); + + // 拖拽第一列到第二列位置 触发宿主持久化回调 + await cut.InvokeAsync(() => table.Instance.DragColumnCallback(0, 1)); + + Assert.NotNull(captured); + Assert.All(captured.Columns, c => Assert.Null(c.Width)); + } + [Fact] public async Task ClearTableColumnClientStatus_Ok() {