diff --git a/build/version.props b/build/version.props
index 991f961a8..88fe86b92 100644
--- a/build/version.props
+++ b/build/version.props
@@ -2,7 +2,7 @@
8
0
- 8
+ 9
$(VersionMajor).$(VersionMinor).$(VersionPatch)
diff --git a/readme.md b/readme.md
index 70643ffa7..000441d5f 100644
--- a/readme.md
+++ b/readme.md
@@ -422,10 +422,6 @@ Util 配套代码生成器, 简单易用, 可解决大部分机械工作.
## Util Angular UI 特点
-- ### 使用 Visual Studio 开发工具
-
- 前端开发一般使用 Visual Studio Code , 不过 Util Angular UI主要使用 Razor 页面,使用 Visual Studio 更方便.
-
- ### 组件扩展支持
除了支持 Ng Zorro 原生功能外,Util UI还对常用组件进行了扩展.
@@ -580,31 +576,11 @@ Util Angular UI 主要由 util-angular 和 Util.Ui.NgZorro 两个库提供支持
## Util Angular UI 已知缺陷
-Util Angular UI 在提供大量支持的同时,也存在一些缺陷.
-
- - ### 开发阶段运行比较缓慢
-
- 与 Visual Studio Code 相比,使用 Visual Studio 开发 Angular 项目要慢一些,如果使用了 Resharper 插件,则会更慢.
-
- 除开发工具影响外, Util Angular UI 在开发阶段需要启用 Angular JIT( 即时编译 ), 运行会变慢.
-
- 另外, Util Angular UI 在开发阶段需要访问 Razor 页面,每当项目启动,Angular 主模块加载的所有组件都会发出 Razor 页面请求.
-
- 不过运行缓慢仅存在于开发阶段,一旦发布,则与纯前端开发方式的运行速度相同.
-
- - ### 无法使用 Angular 常规延迟加载方式
-
- 你不能使用 loadChildren 延迟加载模块,这是因为开发阶段组件的 templateUrl 指向 Razor 页面地址, 必须使用 Angular JIT 模式,等待运行时再获取组件模板.
-
- 这个问题从 Angular 13 开始出现, Angular 13弃用了传统的视图引擎, 使用 loadChildren 加载指向 Razor 页面地址的组件会报异常.
-
- 解决它的方法是使用微前端方案延迟加载模块, 当然你也可以回退到 Angular 13之前的版本.
-
- 在同一个 Util Angular UI 项目中,你必须把所有的子模块加载到主模块中,并配置微前端将子模块发布为可独立加载包.
+Util Angular UI 所有已知缺陷均已解决.
## Util Angular UI 适合你吗?
-Util Angular UI 是为 .Net 全栈工程师准备的,如果你更喜欢使用 Visual Studio 开发,喜欢代码提示,喜欢更简洁的语法,希望开发的成本更低,它就适合你.
+Util Angular UI 是为 .Net 全栈工程师准备的,如果你喜欢更简洁的语法,希望开发的成本更低,它就适合你.
## 参考应用框架
diff --git a/src/Util.AspNetCore/Helpers/Web.cs b/src/Util.AspNetCore/Helpers/Web.cs
index 6e375ccf2..f28ef9e83 100644
--- a/src/Util.AspNetCore/Helpers/Web.cs
+++ b/src/Util.AspNetCore/Helpers/Web.cs
@@ -368,8 +368,8 @@ public static async Task DownloadAsync( byte[] bytes, string fileName, Encoding
fileName = fileName.Replace( " ", "" );
fileName = UrlEncode( fileName, encoding );
Response.ContentType = "application/octet-stream";
- Response.Headers.Add( "Content-Disposition", $"attachment; filename={fileName}" );
- Response.Headers.Add( "Content-Length", bytes.Length.ToString() );
+ Response.Headers.Append( "Content-Disposition", $"attachment; filename={fileName}" );
+ Response.Headers.Append( "Content-Length", bytes.Length.ToString() );
await Response.Body.WriteAsync( bytes, 0, bytes.Length );
}
diff --git a/src/Util.Core/Helpers/Environment.cs b/src/Util.Core/Helpers/Environment.cs
index 579345c2a..d81cbd608 100644
--- a/src/Util.Core/Helpers/Environment.cs
+++ b/src/Util.Core/Helpers/Environment.cs
@@ -20,6 +20,10 @@ public static class Environment {
/// 换行符
///
public static string NewLine => System.Environment.NewLine;
+ ///
+ /// 是否测试环境
+ ///
+ public static bool IsTest { get; set; }
///
/// 设置环境变量
diff --git a/src/Util.Core/Helpers/Url.cs b/src/Util.Core/Helpers/Url.cs
index aed5fa3be..ee714a4a3 100644
--- a/src/Util.Core/Helpers/Url.cs
+++ b/src/Util.Core/Helpers/Url.cs
@@ -16,6 +16,11 @@ public static string JoinPath( params string[] paths ) {
return string.Empty;
var firstPath = paths.First();
var lastPath = paths.Last();
+ string schema = string.Empty;
+ if ( firstPath.StartsWith( "http:", StringComparison.OrdinalIgnoreCase ) )
+ schema = "http://";
+ if ( firstPath.StartsWith( "https:", StringComparison.OrdinalIgnoreCase ) )
+ schema = "https://";
paths = paths.Select( t => t.Trim( '/' ) ).ToArray();
var result = Path.Combine( paths ).Replace( @"\", "/" );
if ( paths.Any( path => path.StartsWith( "." ) ) ) {
@@ -26,6 +31,9 @@ public static string JoinPath( params string[] paths ) {
result = $"/{result}";
if ( lastPath.EndsWith( '/' ) )
result = $"{result}/";
- return result;
+ result = result.RemoveStart( "http:/" ).RemoveStart( "https:/" );
+ if (schema.IsEmpty())
+ return result;
+ return schema + result.RemoveStart( "/" );
}
}
\ No newline at end of file
diff --git a/src/Util.Generators.Razor/RazorTemplate.cs b/src/Util.Generators.Razor/RazorTemplate.cs
index c96811b5a..391666c0f 100644
--- a/src/Util.Generators.Razor/RazorTemplate.cs
+++ b/src/Util.Generators.Razor/RazorTemplate.cs
@@ -2,7 +2,7 @@
using Util.Generators.Templates;
using Util.Templates;
-namespace Util.Generators.Razor;
+namespace Util.Generators.Razor;
///
/// Razor模板
@@ -82,8 +82,10 @@ protected virtual async Task RenderResult( EntityContext context ) {
///
/// 实体上下文
/// 渲染结果
- protected virtual async Task WriteFile( EntityContext context,string result ) {
- if( context.Output.Path.IsEmpty() )
+ protected virtual async Task WriteFile( EntityContext context, string result ) {
+ if ( context.Output.Path.IsEmpty() )
+ return;
+ if ( result.IsEmpty() )
return;
await Util.Helpers.File.WriteAsync( context.Output.Path, result );
}
diff --git a/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj b/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj
index d9ad5c057..94c3d9478 100644
--- a/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj
+++ b/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj
@@ -27,10 +27,10 @@
-
-
+
+
-
+
diff --git a/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs b/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs
index 99cfaa45b..d6f40875a 100644
--- a/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs
+++ b/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs
@@ -1,7 +1,7 @@
using Util.Ui.Angular.Configs;
using Util.Ui.Configs;
-namespace Util.Ui.Angular.Extensions;
+namespace Util.Ui.Angular.Extensions;
///
/// 配置扩展
@@ -13,13 +13,30 @@ public static class ConfigExtensions {
/// 配置
public static Config CopyRemoveAttributes( this Config config ) {
var result = config.Copy();
- result.RemoveAttribute( UiConst.Id );
- result.RemoveAttribute( AngularConst.RawId );
- result.RemoveAttribute( UiConst.Name );
- result.RemoveAttribute( AngularConst.BindName );
- result.RemoveAttribute( UiConst.Style );
- result.RemoveAttribute( UiConst.Class );
result.OutputAttributes.Clear();
+ result.AllAttributes.Clear();
+ LoadConfig( config, result, UiConst.Required );
+ LoadConfig( config, result, UiConst.RequiredMessage );
+ LoadConfig( config, result, UiConst.Suffix );
+ LoadConfig( config, result, AngularConst.BindSuffix );
+ LoadConfig( config, result, UiConst.Extra );
+ LoadConfig( config, result, AngularConst.BindExtra );
+ LoadConfig( config, result, UiConst.ErrorTip );
+ LoadConfig( config, result, AngularConst.BindErrorTip );
+ LoadConfig( config, result, UiConst.SuccessTip );
+ LoadConfig( config, result, AngularConst.BindSuccessTip );
+ LoadConfig( config, result, UiConst.ValidatingTip );
+ LoadConfig( config, result, AngularConst.BindValidatingTip );
+ LoadConfig( config, result, UiConst.WarningTip );
+ LoadConfig( config, result, AngularConst.BindWarningTip );
return result;
}
+
+ ///
+ /// 加载配置
+ ///
+ private static void LoadConfig( Config from, Config to, string name ) {
+ var value = from.GetValue( name );
+ to.SetAttribute( name, value );
+ }
}
\ No newline at end of file
diff --git a/src/Util.Ui.NgZorro/AppBuilderExtensions.cs b/src/Util.Ui.NgZorro/AppBuilderExtensions.cs
index 2b47c9378..db1bf264d 100644
--- a/src/Util.Ui.NgZorro/AppBuilderExtensions.cs
+++ b/src/Util.Ui.NgZorro/AppBuilderExtensions.cs
@@ -31,6 +31,8 @@ public static IAppBuilder AddNgZorro( this IAppBuilder builder, Action();
services.TryAddSingleton();
services.TryAddSingleton();
+ if ( options.EnableWatchRazor )
+ services.AddHostedService();
ConfigSpaStaticFiles( services, options );
ConfigRazorOptions( services, options );
ConfigNgZorroOptions( services, setupAction );
@@ -56,7 +58,11 @@ void Action( RazorOptions t ) {
t.GenerateHtmlBasePath = options.GenerateHtmlBasePath;
t.GenerateHtmlFolder = options.GenerateHtmlFolder;
t.GenerateHtmlSuffix = options.GenerateHtmlSuffix;
+ t.EnableWatchRazor = options.EnableWatchRazor;
+ t.StartInitDelay = options.StartInitDelay;
t.HtmlRenderDelayOnRazorChange = options.HtmlRenderDelayOnRazorChange;
+ t.EnablePreheat = options.EnablePreheat;
+ t.EnableOverrideHtml = options.EnableOverrideHtml;
}
services.Configure( (Action)Action );
}
diff --git a/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs b/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs
index e2e3eee38..2c1f03ef4 100644
--- a/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs
+++ b/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using Util.Ui.Angular.TagHelpers;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Components.Buttons.Renders;
using Util.Ui.NgZorro.Enums;
using Util.Ui.Renders;
diff --git a/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs b/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs
index 268e8a979..0aa4a0267 100644
--- a/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs
+++ b/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs
@@ -93,6 +93,10 @@ private void ConfigValue() {
LoadDate( value );
return;
}
+ if ( dataType == DataType.Number ) {
+ LoadNumber( value );
+ return;
+ }
SetContent( "{{" + GetValue(value) + "}}");
}
@@ -114,6 +118,13 @@ protected void LoadDate( string value ) {
SetContent( $"{{{{{value}|date:\"{format}\"}}}}" );
}
+ ///
+ /// 加载数值
+ ///
+ protected void LoadNumber( string value ) {
+ SetContent( "{{" + value + "}}" );
+ }
+
///
/// 获取值
///
diff --git a/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs b/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs
index b5422a3c6..3b7e27183 100644
--- a/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs
+++ b/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs
@@ -1,5 +1,4 @@
-using Util.Ui.Configs;
-using Util.Ui.Expressions;
+using Util.Ui.Expressions;
using Util.Ui.NgZorro.Components.Forms.Configs;
using Util.Ui.NgZorro.Enums;
using Util.Ui.NgZorro.Expressions;
@@ -53,5 +52,9 @@ protected virtual void LoadValue( Config config, ModelExpressionInfo info ) {
config.SetAttribute( UiConst.Type, DataType.Date );
return;
}
+ if ( info.IsNumber ) {
+ config.SetAttribute( UiConst.Type, DataType.Number );
+ return;
+ }
}
}
\ No newline at end of file
diff --git a/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs b/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs
index 16b3cd728..79c273f99 100644
--- a/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs
+++ b/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs
@@ -1,5 +1,4 @@
using Util.Ui.Angular.Configs;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Enums;
using Util.Ui.NgZorro.Components.Inputs.Configs;
using Util.Ui.Angular.Builders;
diff --git a/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs b/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs
index 2fe93bec9..5f2ac8de2 100644
--- a/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs
+++ b/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs
@@ -1,5 +1,4 @@
using Util.Ui.Angular.Configs;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Components.Base;
using Util.Ui.NgZorro.Components.Inputs.Helpers;
using Util.Ui.NgZorro.Enums;
diff --git a/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs b/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs
index 43c20cb91..81d8e4645 100644
--- a/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs
+++ b/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs
@@ -1,6 +1,5 @@
using Util.Ui.Angular.Configs;
using Util.Ui.Builders;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Enums;
namespace Util.Ui.NgZorro.Components.Popover;
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs
index a0b573a32..79f9d7df3 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs
@@ -1,8 +1,10 @@
using Util.Ui.Angular.Builders;
using Util.Ui.Angular.Configs;
+using Util.Ui.Angular.Extensions;
using Util.Ui.Builders;
using Util.Ui.NgZorro.Components.Tables.Builders.Contents;
using Util.Ui.NgZorro.Components.Tables.Configs;
+using Util.Ui.NgZorro.Directives.Tooltips;
namespace Util.Ui.NgZorro.Components.Tables.Builders;
@@ -289,6 +291,7 @@ public TableColumnBuilder EnableCustomColumn() {
public TableColumnBuilder Events() {
AttributeIfNotEmpty( "(nzCheckedChange)", _config.GetValue( UiConst.OnCheckedChange ) );
AttributeIfNotEmpty( "(nzExpandChange)", _config.GetValue( UiConst.OnExpandChange ) );
+ this.OnClick( _config );
return this;
}
@@ -301,7 +304,7 @@ public override void Config() {
.ShowExpand().Expand()
.Left().Right().Align().BreakWord().Ellipsis()
.IndentSize().CellControl().EnableCustomColumn()
- .Events();
+ .Tooltip( _config ).Events();
ConfigContent();
}
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs
index 153d1a4aa..38c7ac427 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs
@@ -1,6 +1,5 @@
using Util.Ui.Angular.Builders;
using Util.Ui.Angular.Extensions;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Components.Tables.Configs;
namespace Util.Ui.NgZorro.Components.Tables.Builders;
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs
index 6a8c1a01b..5dde35288 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs
@@ -39,9 +39,9 @@ public TableShareConfig GetTableShareConfig() {
/// 设置表格尺寸
///
public TableSettingsBuilder IsTreeTable() {
- if (_tableShareConfig.IsTreeTable == false)
+ if ( _tableShareConfig.IsTreeTable == false )
return this;
- Attribute( "[isTreeTable]","true" );
+ Attribute( "[isTreeTable]", "true" );
return this;
}
@@ -75,8 +75,8 @@ public TableSettingsBuilder Scroll() {
/// 配置是否启用固定列
///
public TableSettingsBuilder EnableFixedColumn() {
- if( _tableShareConfig.IsEnableFixedColumn )
- Attribute( "[enableFixedColumn]","true" );
+ if ( _tableShareConfig.IsEnableFixedColumn )
+ Attribute( "[enableFixedColumn]", "true" );
return this;
}
@@ -98,9 +98,9 @@ private string GetColumnsJson() {
AddRadioColumn( result );
AddCheckboxColumn( result );
AddLineNumberColumn( result );
- result.AddRange( _tableShareConfig.Columns.Select( column => column.ToCustomColumn() ) );
+ result.AddRange( _tableShareConfig.Columns.Where( column => column.IsInner == false ).Select( column => column.ToCustomColumn() ) );
var json = Util.Helpers.Json.ToJson( result, new JsonOptions { ToSingleQuotes = true, IgnoreNullValues = true } );
- return json.Replace($"'{GetCheckboxWidth()}'", GetCheckboxWidth() )
+ return json.Replace( $"'{GetCheckboxWidth()}'", GetCheckboxWidth() )
.Replace( $"'{GetRadioWidth()}'", GetRadioWidth() )
.Replace( $"'{GetLineNumberWidth()}'", GetLineNumberWidth() );
}
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Configs/TableColumnDisplayShareConfig.cs b/src/Util.Ui.NgZorro/Components/Tables/Configs/TableColumnDisplayShareConfig.cs
new file mode 100644
index 000000000..99e708fce
--- /dev/null
+++ b/src/Util.Ui.NgZorro/Components/Tables/Configs/TableColumnDisplayShareConfig.cs
@@ -0,0 +1,7 @@
+namespace Util.Ui.NgZorro.Components.Tables.Configs;
+
+///
+/// 表格编辑列显示区域共享配置
+///
+public class TableColumnDisplayShareConfig {
+}
\ No newline at end of file
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs b/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs
index 93238b3bf..9237dd84f 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs
@@ -64,6 +64,10 @@ public class ColumnInfo {
/// 是否启用拖动调整列宽
///
public bool IsEnableResizable { get; set; }
+ ///
+ /// 是否内部列
+ ///
+ public bool IsInner { get; set; }
///
/// 转换为自定义列
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnDisplayService.cs b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnDisplayService.cs
new file mode 100644
index 000000000..80b0f3514
--- /dev/null
+++ b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnDisplayService.cs
@@ -0,0 +1,40 @@
+using Util.Ui.NgZorro.Components.Tables.Configs;
+
+namespace Util.Ui.NgZorro.Components.Tables.Helpers;
+
+///
+/// 表格编辑列显示服务
+///
+public class TableColumnDisplayService {
+ ///
+ /// 配置
+ ///
+ private readonly Config _config;
+ ///
+ /// 表格编辑列显示区域共享配置
+ ///
+ private TableColumnDisplayShareConfig _shareConfig;
+
+ ///
+ /// 初始化表格编辑列显示服务
+ ///
+ /// 配置
+ public TableColumnDisplayService( Config config ) {
+ _config = config;
+ }
+
+ ///
+ /// 初始化
+ ///
+ public void Init() {
+ CreateShareConfig();
+ }
+
+ ///
+ /// 创建共享配置
+ ///
+ private void CreateShareConfig() {
+ _shareConfig = new TableColumnDisplayShareConfig();
+ _config.SetValueToItems( _shareConfig );
+ }
+}
\ No newline at end of file
diff --git a/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs
index 3de69a03f..a63003d98 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs
@@ -103,6 +103,8 @@ private ColumnInfo GetColumnInfo() {
AclElseTemplateId = _config.GetValue( UiConst.AclElseTemplateId ),
IsEnableResizable = _config.GetValue( UiConst.EnableResizable )
};
+ if ( IsInColumnDisplay() )
+ result.IsInner = true;
return result;
}
@@ -128,4 +130,12 @@ private string GetOperationTitle() {
return "util.operation";
return "Operation";
}
+
+ ///
+ /// 是否包含在显示列中
+ ///
+ private bool IsInColumnDisplay() {
+ var displayShareConfig = _config.GetValueFromItems();
+ return displayShareConfig != null;
+ }
}
\ No newline at end of file
diff --git a/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs b/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs
index 470876982..47cb7e763 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using Util.Ui.Angular.TagHelpers;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Components.Tables.Helpers;
using Util.Ui.NgZorro.Components.Tables.Renders;
using Util.Ui.Renders;
diff --git a/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs b/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs
index e6bcec1bd..4826d3ff3 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using Util.Ui.Angular.TagHelpers;
-using Util.Ui.Configs;
+using Util.Ui.NgZorro.Components.Tables.Helpers;
using Util.Ui.NgZorro.Components.Tables.Renders;
using Util.Ui.Renders;
@@ -19,6 +19,8 @@ public class TableColumnDisplayTagHelper : AngularTagHelperBase {
///
protected override void ProcessBefore( TagHelperContext context, TagHelperOutput output ) {
_config = new Config( context, output );
+ var service = new TableColumnDisplayService( _config );
+ service.Init();
}
///
diff --git a/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs b/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs
index 28ce813ad..716df3e6a 100644
--- a/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs
+++ b/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs
@@ -1,6 +1,6 @@
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
-using Util.Ui.Angular.TagHelpers;
+using Util.Ui.NgZorro.Components.Base;
using Util.Ui.NgZorro.Components.Tables.Helpers;
using Util.Ui.NgZorro.Components.Tables.Renders;
using Util.Ui.NgZorro.Enums;
@@ -12,7 +12,7 @@ namespace Util.Ui.NgZorro.Components.Tables;
/// 表格单元格,生成的标签为<td></td>
///
[HtmlTargetElement( "util-td" )]
-public class TableColumnTagHelper : AngularTagHelperBase {
+public class TableColumnTagHelper : TooltipTagHelperBase {
///
/// 配置
///
@@ -184,6 +184,10 @@ public class TableColumnTagHelper : AngularTagHelperBase {
/// (nzExpandChange),展开状态变化事件,类型: EventEmitter<boolean>
///
public string OnExpandChange { get; set; }
+ ///
+ /// (click),单击事件
+ ///
+ public string OnClick { get; set; }
///
protected override void ProcessBefore( TagHelperContext context, TagHelperOutput output ) {
diff --git a/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs b/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs
index d2fd49ce6..beee3de02 100644
--- a/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs
+++ b/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs
@@ -1,6 +1,5 @@
using Util.Ui.Angular.Configs;
using Util.Ui.Builders;
-using Util.Ui.Configs;
using Util.Ui.NgZorro.Configs;
using Util.Ui.NgZorro.Enums;
using Util.Ui.NgZorro.Extensions;
diff --git a/src/Util.Ui.NgZorro/Enums/DataType.cs b/src/Util.Ui.NgZorro/Enums/DataType.cs
index 87d6dec20..26e1ce317 100644
--- a/src/Util.Ui.NgZorro/Enums/DataType.cs
+++ b/src/Util.Ui.NgZorro/Enums/DataType.cs
@@ -13,6 +13,10 @@ public enum DataType {
///
Date,
///
+ /// 数值
+ ///
+ Number,
+ ///
/// 枚举
///
Enum
diff --git a/src/Util.Ui.NgZorro/NgZorroOptions.cs b/src/Util.Ui.NgZorro/NgZorroOptions.cs
index 58b6568d4..c5d8fdd20 100644
--- a/src/Util.Ui.NgZorro/NgZorroOptions.cs
+++ b/src/Util.Ui.NgZorro/NgZorroOptions.cs
@@ -1,17 +1,29 @@
using Util.Ui.NgZorro.Enums;
-namespace Util.Ui.NgZorro;
+namespace Util.Ui.NgZorro;
///
/// NgZorro配置
///
public class NgZorroOptions {
+ ///
+ /// 初始化NgZorro配置
+ ///
+ public NgZorroOptions() {
+ if ( Util.Helpers.Environment.IsTest ) {
+ EnableDefaultOptionText = false;
+ EnableI18n = false;
+ EnableAllowClear = false;
+ EnableWatchRazor = false;
+ }
+ }
+
///
/// Spa静态文件根路径,默认值: ClientApp
///
public string RootPath { get; set; } = "ClientApp";
///
- /// 是否生成html
+ /// 是否自动生成html,默认值: false
///
internal bool IsGenerateHtml { get; set; }
///
@@ -27,25 +39,41 @@ public class NgZorroOptions {
///
public string GenerateHtmlSuffix { get; set; } = "component.html";
///
+ /// Razor监听服务启动初始化的延迟时间,单位: 毫秒, 默认值:1000, 注意: 需要等待Web服务启动完成才能开始初始化
+ ///
+ public int StartInitDelay { get; set; } = 1000;
+ ///
/// 修改Razor页面生成Html文件的延迟时间,单位: 毫秒, 默认值:100 ,注意: 延迟太短可能导致生成异常
///
public int HtmlRenderDelayOnRazorChange { get; set; } = 100;
///
- /// 是否启用默认项文本
+ /// 是否启用默认项文本,默认值: true
///
- public bool EnableDefaultOptionText { get; set; }
+ public bool EnableDefaultOptionText { get; set; } = true;
///
- /// 是否启用多语言
+ /// 是否启用多语言,默认值: true
///
- public bool EnableI18n { get; set; }
+ public bool EnableI18n { get; set; } = true;
///
- /// 是否启用全局输入框设置 autocomplete="off"
+ /// 是否启用全局输入框设置 autocomplete="off",默认值: false
///
public bool EnableAutocompleteOff { get; set; }
///
- /// 是否启用允许清除输入框
+ /// 是否启用允许清除输入框,默认值: true
+ ///
+ public bool EnableAllowClear { get; set; } = true;
+ ///
+ /// 是否启用Razor监视服务,默认值: true
+ ///
+ public bool EnableWatchRazor { get; set; } = true;
+ ///
+ /// 启动Razor监视服务时是否预热,默认值: true
+ ///
+ public bool EnablePreheat { get; set; } = true;
+ ///
+ /// Razor生成是否覆盖已存在的html文件,默认值: true
///
- public bool EnableAllowClear { get; set; }
+ public bool EnableOverrideHtml { get; set; } = true;
///
/// 获取表格布尔列内容操作
///
diff --git a/src/Util.Ui.NgZorro/WatchHostedService.cs b/src/Util.Ui.NgZorro/WatchHostedService.cs
new file mode 100644
index 000000000..f5133b8bb
--- /dev/null
+++ b/src/Util.Ui.NgZorro/WatchHostedService.cs
@@ -0,0 +1,35 @@
+using Util.Ui.Razor;
+
+namespace Util.Ui.NgZorro;
+
+///
+/// Razor页面监视服务
+///
+public class WatchHostedService : IHostedService {
+ ///
+ /// Razor页面监听服务
+ ///
+ private readonly IRazorWatchService _service;
+
+ ///
+ /// 初始化Razor页面监视服务
+ ///
+ /// Razor页面监听服务
+ public WatchHostedService( IRazorWatchService service ) {
+ _service = service;
+ }
+
+ ///
+ /// 启动服务
+ ///
+ public async Task StartAsync( CancellationToken cancellationToken ) {
+ await _service.StartAsync( cancellationToken );
+ }
+
+ ///
+ /// 停止服务
+ ///
+ public async Task StopAsync( CancellationToken cancellationToken ) {
+ await _service.StopAsync( cancellationToken );
+ }
+}
\ No newline at end of file
diff --git a/src/Util.Ui.NgZorro/WebApplicationExtensions.cs b/src/Util.Ui.NgZorro/WebApplicationExtensions.cs
index 14be2d30a..00f19571d 100644
--- a/src/Util.Ui.NgZorro/WebApplicationExtensions.cs
+++ b/src/Util.Ui.NgZorro/WebApplicationExtensions.cs
@@ -1,20 +1,39 @@
using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.SpaServices;
+using Util.Ui.Sources.Spa.AngularCli;
+using Util.Ui.Sources.Spa;
-namespace Util.Ui.NgZorro;
+namespace Util.Ui.NgZorro;
///
/// NgZorro配置扩展
///
public static class WebApplicationExtensions {
+ ///
+ /// ClientApp
+ ///
+ private const string SourcePath = "ClientApp";
+
///
/// 配置NgZorro应用
///
/// Web应用
- /// 配置Spa操作
- public static WebApplication UseNgZorro( this WebApplication app, Action action ) {
+ public static WebApplication UseNgZorro( this WebApplication app ) {
+ app.CheckNull( nameof( app ) );
+ AddEndpoints( app );
+ if ( app.Environment.IsDevelopment() == false )
+ return app;
+ app.UseAngular( spa => {
+ spa.Options.SourcePath = SourcePath;
+ spa.UseAngularCliServer( "start" );
+ } );
+ return app;
+ }
+
+ ///
+ /// 添加路由端点
+ ///
+ private static void AddEndpoints( WebApplication app ) {
app.CheckNull( nameof( app ) );
- action.CheckNull( nameof( action ) );
app.UseStaticFiles();
app.UseSpaStaticFiles();
app.UseRouting();
@@ -24,7 +43,20 @@ public static WebApplication UseNgZorro( this WebApplication app, Action
+ /// 配置NgZorro应用
+ ///
+ /// Web应用
+ /// 配置Spa操作
+ public static WebApplication UseNgZorro( this WebApplication app, Action action ) {
+ app.CheckNull( nameof( app ) );
+ AddEndpoints( app );
+ if ( app.Environment.IsDevelopment() == false )
+ return app;
+ if ( action != null )
+ app.UseSpa( action );
return app;
}
@@ -33,12 +65,52 @@ public static WebApplication UseNgZorro( this WebApplication app, Action
/// Web应用
/// 开发服务器基地址,范例: http://localhost:5000
- public static WebApplication UseNgZorro( this WebApplication app, string developmentServerBaseUri ) {
+ /// 是否自动启动Angular服务器,默认值: true
+ public static WebApplication UseNgZorro( this WebApplication app, string developmentServerBaseUri, bool isAutoStartAngularServer = true ) {
app.CheckNull( nameof( app ) );
- return app.UseNgZorro( spa => {
- spa.Options.SourcePath = "ClientApp";
- if ( app.Environment.IsDevelopment() )
- spa.UseProxyToSpaDevelopmentServer( developmentServerBaseUri );
+ var port = new Uri( developmentServerBaseUri ).Port;
+ return app.UseNgZorro( port, isAutoStartAngularServer );
+ }
+
+ ///
+ /// 配置NgZorro应用
+ ///
+ /// Web应用
+ /// 开发服务器端口号
+ /// 是否自动启动Angular服务器,默认值: true
+ public static WebApplication UseNgZorro( this WebApplication app, int port, bool isAutoStartAngularServer = true ) {
+ app.CheckNull( nameof( app ) );
+ return isAutoStartAngularServer ? UseCustomSpa( app, port ) : UseSpa( app, $"http://localhost:{port}" );
+ }
+
+ ///
+ /// 使用官方SPA实现
+ ///
+ private static WebApplication UseSpa( WebApplication app, string developmentServerBaseUri ) {
+ app.CheckNull( nameof( app ) );
+ AddEndpoints( app );
+ if ( app.Environment.IsDevelopment() == false )
+ return app;
+ app.UseSpa( spa => {
+ spa.Options.SourcePath = SourcePath;
+ spa.UseProxyToSpaDevelopmentServer( developmentServerBaseUri );
} );
+ return app;
+ }
+
+ ///
+ /// 使用SPA扩展实现
+ ///
+ private static WebApplication UseCustomSpa( WebApplication app, int port ) {
+ app.CheckNull( nameof( app ) );
+ AddEndpoints( app );
+ if ( app.Environment.IsDevelopment() == false )
+ return app;
+ app.UseAngular( spa => {
+ spa.Options.SourcePath = SourcePath;
+ spa.Options.DevServerPort = port;
+ spa.UseAngularCliServer( "start" );
+ } );
+ return app;
}
}
\ No newline at end of file
diff --git a/src/Util.Ui/01-Util.Ui.csproj b/src/Util.Ui/01-Util.Ui.csproj
index 98913b7f2..a4130693c 100644
--- a/src/Util.Ui/01-Util.Ui.csproj
+++ b/src/Util.Ui/01-Util.Ui.csproj
@@ -29,6 +29,7 @@
+
diff --git a/src/Util.Ui/Razor/GenerateHtmlFilter.cs b/src/Util.Ui/Razor/GenerateHtmlFilter.cs
index 497e770dd..8d9704078 100644
--- a/src/Util.Ui/Razor/GenerateHtmlFilter.cs
+++ b/src/Util.Ui/Razor/GenerateHtmlFilter.cs
@@ -30,6 +30,11 @@ public async Task OnPageHandlerSelectionAsync( PageHandlerSelectedContext contex
var path = CreatePath( context, options );
if ( string.IsNullOrWhiteSpace( path ) )
return;
+ if ( options.EnableOverrideHtml == false ) {
+ var filePath = Util.Helpers.Web.GetPhysicalPath( path );
+ if ( File.Exists( filePath ) )
+ return;
+ }
var log = GetLogger( context );
try {
var html = await GetHtml( context );
@@ -37,7 +42,7 @@ public async Task OnPageHandlerSelectionAsync( PageHandlerSelectedContext contex
log.LogDebug( $"Razor生成Html成功: Razor Path: {context.ActionDescriptor.ViewEnginePath}, Html Path: {path}" );
}
catch ( Exception exception ) {
- log.LogError(exception, $"Razor页面生成 html 失败: razor path: {context.ActionDescriptor.ViewEnginePath}" );
+ log.LogError( exception, $"Razor页面生成 html 失败: razor path: {context.ActionDescriptor.ViewEnginePath}" );
throw;
}
}
@@ -58,9 +63,7 @@ private string CreatePath( PageHandlerSelectedContext context, RazorOptions opti
return GetPath( context?.ActionDescriptor.ViewEnginePath, options.GenerateHtmlBasePath, options.GenerateHtmlFolder, options.GenerateHtmlSuffix );
if ( attribute.Ignore )
return string.Empty;
- if ( string.IsNullOrWhiteSpace( attribute.Path ) )
- return string.Empty;
- return attribute.Path;
+ return string.IsNullOrWhiteSpace( attribute.Path ) ? string.Empty : attribute.Path;
}
///
@@ -70,7 +73,7 @@ private string CreatePath( PageHandlerSelectedContext context, RazorOptions opti
/// 基路径,默认值:/ClientApp/src/app
/// html文件目录名称,默认值:html
/// html文件后缀,默认值:component.html
- public static string GetPath( string path, string basePath = "/ClientApp/src/app",string folder = "html", string htmlSuffix = "component.html" ) {
+ public static string GetPath( string path, string basePath = "/ClientApp/src/app", string folder = "html", string htmlSuffix = "component.html" ) {
if ( string.IsNullOrWhiteSpace( path ) )
return string.Empty;
path = path.Kebaberize().ToLower().Trim( '\\' ).Trim( '/' );
@@ -115,8 +118,6 @@ private IRazorPage FindPage( IRazorViewEngine razorViewEngine, string pageName )
/// 写入文件
///
private async Task WriteFile( string path, string html ) {
- if ( string.IsNullOrWhiteSpace( html ) )
- return;
path = Util.Helpers.Web.GetPhysicalPath( path );
var directory = Path.GetDirectoryName( path );
if ( string.IsNullOrWhiteSpace( directory ) )
diff --git a/src/Util.Ui/Razor/HtmlGenerator.cs b/src/Util.Ui/Razor/HtmlGenerator.cs
index d6b246aa1..e82e35cab 100644
--- a/src/Util.Ui/Razor/HtmlGenerator.cs
+++ b/src/Util.Ui/Razor/HtmlGenerator.cs
@@ -14,7 +14,7 @@ public static class HtmlGenerator {
/// 取消令牌
public static async Task GenerateAsync( string path, bool isGenerateHtml = true, CancellationToken cancellationToken = default ) {
EnableGenerateHtml( isGenerateHtml );
- var requestPath = $"{GetHost()}/view/{path.TrimStart( "/Pages".ToCharArray() ).TrimEnd( ".cshtml".ToCharArray() )}";
+ var requestPath = Url.JoinPath( GetHost(), "view", path.RemoveStart( "/Pages" ).RemoveEnd( ".cshtml" ) );
return await Web.Client.Get( requestPath ).GetResultAsync( cancellationToken );
}
diff --git a/src/Util.Ui/Razor/Internal/RazorViewContainer.cs b/src/Util.Ui/Razor/Internal/RazorViewContainer.cs
index 98673d32f..92d946129 100644
--- a/src/Util.Ui/Razor/Internal/RazorViewContainer.cs
+++ b/src/Util.Ui/Razor/Internal/RazorViewContainer.cs
@@ -31,10 +31,24 @@ public RazorViewContainer( IPartViewPathResolver partViewPathResolver, IViewCont
///
/// 获取全部视图
///
- public List GetAll() {
+ public List GetAllViews() {
return _views.Values.ToList();
}
+ ///
+ /// 获取全部主视图
+ ///
+ public List GetMainViews() {
+ return _views.Values.Where( view => view is MainView ).ToList();
+ }
+
+ ///
+ /// 获取全部主视图路径
+ ///
+ public List GetMainViewPaths() {
+ return GetMainViews().Select( t => t.Path ).ToList();
+ }
+
///
/// 获取随机视图路径
///
@@ -69,7 +83,7 @@ public void AddView( RazorView view ) {
public void Init( IDictionary viewContents ) {
if ( viewContents == null )
return;
- foreach ( var viewContent in viewContents )
+ foreach ( var viewContent in viewContents )
CreateView( viewContent.Key, viewContent.Value );
}
diff --git a/src/Util.Ui/Razor/RazorOptions.cs b/src/Util.Ui/Razor/RazorOptions.cs
index 5ace853dc..2ca4395df 100644
--- a/src/Util.Ui/Razor/RazorOptions.cs
+++ b/src/Util.Ui/Razor/RazorOptions.cs
@@ -21,7 +21,23 @@ public class RazorOptions {
///
public string GenerateHtmlSuffix { get; set; } = "component.html";
///
+ /// 是否启用Razor监视服务,默认值: true
+ ///
+ public bool EnableWatchRazor { get; set; } = true;
+ ///
+ /// Razor监听服务启动初始化的延迟时间,单位: 毫秒, 默认值:1000, 注意: 需要等待Web服务启动完成才能开始初始化
+ ///
+ public int StartInitDelay { get; set; } = 1000;
+ ///
/// 修改Razor页面生成Html文件的延迟时间,单位: 毫秒, 默认值:100 ,注意: 延迟太短可能导致生成异常
///
- public int HtmlRenderDelayOnRazorChange { get; set; } = 100;
+ public int HtmlRenderDelayOnRazorChange { get; set; } = 100;
+ ///
+ /// 启动Razor监视服务时是否预热,默认值: true
+ ///
+ public bool EnablePreheat { get; set; } = true;
+ ///
+ /// 启用Razor生成覆盖已存在的html文件,默认值: true
+ ///
+ public bool EnableOverrideHtml { get; set; } = true;
}
\ No newline at end of file
diff --git a/src/Util.Ui/Razor/RazorWatchService.cs b/src/Util.Ui/Razor/RazorWatchService.cs
index 99fb90684..719b7886d 100644
--- a/src/Util.Ui/Razor/RazorWatchService.cs
+++ b/src/Util.Ui/Razor/RazorWatchService.cs
@@ -1,5 +1,4 @@
-using System.Net.Http;
-using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration;
using Util.Helpers;
using Util.Ui.Razor.Internal;
@@ -9,6 +8,10 @@ namespace Util.Ui.Razor;
/// Razor页面监听服务
///
public class RazorWatchService : IRazorWatchService {
+ ///
+ /// 是否启动完成
+ ///
+ public static bool IsStartComplete { get; set; }
///
/// 文件监视器
///
@@ -33,12 +36,17 @@ public class RazorWatchService : IRazorWatchService {
/// Razor配置
///
private readonly RazorOptions _options;
+ ///
+ /// 是否生成缺失的html
+ ///
+ private bool _isGenerateMissingHtml;
///
/// 初始化Razor页面监听服务
///
- public RazorWatchService( IPartViewPathResolver resolver, HttpClient client,
+ public RazorWatchService( IServiceProvider serviceProvider, IPartViewPathResolver resolver, HttpClient client,
IActionDescriptorCollectionProvider provider, IOptions options ) {
+ Ioc.SetServiceProviderAction( () => serviceProvider );
_watcher = new FileWatcher();
_resolver = resolver;
_client = client;
@@ -51,15 +59,22 @@ public virtual async Task StartAsync( CancellationToken cancellationToken ) {
WriteLog( "启动中..." );
WriteLog( "准备初始化..." );
InitRazorViewContainer();
- await Preheat( cancellationToken );
- WriteLog( "初始化完成." );
- await StartWatch( cancellationToken );
+ await Task.Factory.StartNew( async () => {
+ await Task.Delay( _options.StartInitDelay, cancellationToken );
+ await GenerateMissingHtml( cancellationToken );
+ await Preheat( cancellationToken );
+ IsStartComplete = true;
+ WriteLog( "初始化完成." );
+ await StartWatch( cancellationToken );
+ }, cancellationToken );
}
///
/// 写入控制台日志
///
- private void WriteLog( string content ) {
+ private void WriteLog( string content, bool isWrite = true ) {
+ if ( isWrite == false )
+ return;
Console.WriteLine( $"dbug: Util应用框架 - Razor监听服务 - {content}" );
}
@@ -95,22 +110,58 @@ protected List GetPageActionDescriptors() {
/// 获取Razor文件内容
///
protected string GetContent( string relativePath ) {
- var path = Url.JoinPath( Common.GetCurrentDirectory(), relativePath );
+ var path = GetProjectPath( relativePath );
return Util.Helpers.File.ReadToString( path );
}
///
- /// Razor页面预热
+ /// 获取项目路径
///
- protected async Task Preheat( CancellationToken cancellationToken ) {
- await Task.Factory.StartNew( async () => {
- await Task.Delay( 1000, cancellationToken );
- WriteLog( "Razor页面准备预热..." );
- var paths = _container.GetRandomPaths();
- foreach ( var path in paths )
- await Request( path, cancellationToken );
- WriteLog( "Razor页面预热完成..." );
- }, cancellationToken );
+ protected string GetProjectPath( string relativePath ) {
+ return Url.JoinPath( Common.GetCurrentDirectory(), relativePath );
+ }
+
+ ///
+ /// 生成缺失的html
+ ///
+ protected async Task GenerateMissingHtml( CancellationToken cancellationToken ) {
+ WriteLog( "准备生成缺失的html..." );
+ EnableGenerateHtml();
+ EnableOverrideHtml( false );
+ var fileBasePath = GetProjectPath( _options.GenerateHtmlBasePath );
+ var files = Util.Helpers.File.GetAllFiles( fileBasePath, "*.html" );
+ foreach ( var path in _container.GetMainViewPaths() ) {
+ if ( Exists( files.Select( t => t.FullName ).ToList(), path ) )
+ continue;
+ _isGenerateMissingHtml = true;
+ await Request( path, cancellationToken );
+ }
+ EnableOverrideHtml();
+ WriteLog( "生成缺失的html完成..." );
+ }
+
+ ///
+ /// 启用Html生成
+ ///
+ protected void EnableGenerateHtml( bool isGenerateHtml = true ) {
+ _options.IsGenerateHtml = isGenerateHtml;
+ }
+
+ ///
+ /// 启用Html覆盖
+ ///
+ protected void EnableOverrideHtml( bool isOverrideHtml = true ) {
+ _options.EnableOverrideHtml = isOverrideHtml;
+ }
+
+ ///
+ /// html是否存在
+ ///
+ protected bool Exists( List htmlPaths, string razorPath ) {
+ if ( string.Equals( razorPath, "/Pages/Error.cshtml", StringComparison.OrdinalIgnoreCase ) )
+ return true;
+ var path = GenerateHtmlFilter.GetPath( razorPath.RemoveStart( "/Pages" ).RemoveEnd( ".cshtml" ) );
+ return htmlPaths.Any( t => t.Replace( "\\", "/" ).EndsWith( path, StringComparison.OrdinalIgnoreCase ) );
}
///
@@ -118,17 +169,21 @@ await Task.Factory.StartNew( async () => {
///
/// 视图路径
/// 取消令牌
- public async Task Request( string path, CancellationToken cancellationToken = default ) {
+ /// 是否写入日志
+ public async Task Request( string path, CancellationToken cancellationToken = default, bool isWrite = true ) {
await Task.Delay( _options.HtmlRenderDelayOnRazorChange, cancellationToken );
- var requestPath = $"{GetApplicationUrl()}/view/{path.TrimStart( "/Pages".ToCharArray() ).TrimEnd( ".cshtml".ToCharArray() )}";
- await _client.GetAsync( requestPath, cancellationToken );
+ var requestPath = Url.JoinPath( GetApplicationUrl(), "view", path.RemoveStart( "/Pages" ).RemoveEnd( ".cshtml" ) );
+ WriteLog( $"发送请求: {requestPath}", isWrite );
+ var response = await _client.GetAsync( requestPath, cancellationToken );
+ if ( response.IsSuccessStatusCode )
+ WriteLog( $"请求成功: {requestPath}", isWrite );
}
///
/// 获取应用地址
///
protected string GetApplicationUrl() {
- var path = Url.JoinPath( Common.GetCurrentDirectory(), "Properties" );
+ var path = GetProjectPath( "Properties" );
var builder = new ConfigurationBuilder()
.SetBasePath( path )
.AddJsonFile( "launchSettings.json", true, false );
@@ -140,12 +195,28 @@ protected string GetApplicationUrl() {
return null;
}
+ ///
+ /// Razor页面预热
+ ///
+ protected async Task Preheat( CancellationToken cancellationToken ) {
+ if ( _isGenerateMissingHtml )
+ return;
+ if ( _options.EnablePreheat == false )
+ return;
+ WriteLog( "Razor页面准备预热..." );
+ EnableGenerateHtml( false );
+ var paths = _container.GetRandomPaths();
+ foreach ( var path in paths )
+ await Request( path, cancellationToken, false );
+ WriteLog( "Razor页面预热完成..." );
+ }
+
///
/// 启动监听器
///
protected virtual Task StartWatch( CancellationToken cancellationToken ) {
WriteLog( "开始监听..." );
- var path = Common.JoinPath( Common.GetCurrentDirectory(), "Pages" );
+ var path = GetProjectPath( "Pages" );
_watcher.Path( path )
.Filter( "*.cshtml" )
.OnChangedAsync( async ( _, e ) => await GenerateAsync( e.FullPath, cancellationToken ) )
@@ -169,10 +240,8 @@ private async Task GenerateAsync( string fullPath, CancellationToken cancellatio
WriteLog( $"未找到可更新的视图路径: {path}" );
return;
}
- foreach ( var viewPath in viewPaths ) {
- WriteLog( $"请求生成: {path}" );
+ foreach ( var viewPath in viewPaths )
await Request( viewPath, cancellationToken );
- }
}
///
@@ -182,13 +251,6 @@ protected bool IsCshtml( string path ) {
return path.EndsWith( ".cshtml", StringComparison.OrdinalIgnoreCase );
}
- ///
- /// 启用Html生成
- ///
- protected void EnableGenerateHtml() {
- _options.IsGenerateHtml = true;
- }
-
///
public virtual Task StopAsync( CancellationToken cancellationToken ) {
WriteLog( "准备关闭服务..." );
diff --git a/src/Util.Ui/Sources/Spa/AngularCli/AngularCliBuilder.cs b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliBuilder.cs
new file mode 100644
index 000000000..c76131441
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliBuilder.cs
@@ -0,0 +1,86 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Hosting;
+using Util.Ui.Sources.Spa.Npm;
+using Util.Ui.Sources.Spa.Prerendering;
+using Util.Ui.Sources.Spa.Util;
+
+namespace Util.Ui.Sources.Spa.AngularCli;
+
+///
+/// Provides an implementation of that can build
+/// an Angular application by invoking the Angular CLI.
+///
+[Obsolete("Prerendering is no longer supported out of box")]
+public class AngularCliBuilder : ISpaPrerendererBuilder
+{
+ private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine
+
+ private readonly string _scriptName;
+
+ ///
+ /// Constructs an instance of .
+ ///
+ /// The name of the script in your package.json file that builds the server-side bundle for your Angular application.
+ public AngularCliBuilder(string npmScript)
+ {
+ if (string.IsNullOrEmpty(npmScript))
+ {
+ throw new ArgumentException("Cannot be null or empty.", nameof(npmScript));
+ }
+
+ _scriptName = npmScript;
+ }
+
+ ///
+ public async Task Build(ISpaBuilder spaBuilder)
+ {
+ var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
+ var sourcePath = spaBuilder.Options.SourcePath;
+ if (string.IsNullOrEmpty(sourcePath))
+ {
+ throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} .");
+ }
+
+ var appBuilder = spaBuilder.ApplicationBuilder;
+ var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService().ApplicationStopping;
+ var logger = LoggerFinder.GetOrCreateLogger(
+ appBuilder,
+ nameof(AngularCliBuilder));
+ var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService();
+ var scriptRunner = new NodeScriptRunner(
+ sourcePath,
+ _scriptName,
+ "--watch",
+ null,
+ pkgManagerCommand,
+ diagnosticSource,
+ applicationStoppingToken);
+ scriptRunner.AttachToLogger(logger);
+
+ using (var stdOutReader = new EventedStreamStringReader(scriptRunner.StdOut))
+ using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr))
+ {
+ try
+ {
+ await scriptRunner.StdOut.WaitForMatch( ["Date"] );
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidOperationException(
+ $"The {pkgManagerCommand} script '{_scriptName}' exited without indicating success.\n" +
+ $"Output was: {stdOutReader.ReadAsString()}\n" +
+ $"Error output was: {stdErrReader.ReadAsString()}", ex);
+ }
+ catch (OperationCanceledException ex)
+ {
+ throw new InvalidOperationException(
+ $"The {pkgManagerCommand} script '{_scriptName}' timed out without indicating success. " +
+ $"Output was: {stdOutReader.ReadAsString()}\n" +
+ $"Error output was: {stdErrReader.ReadAsString()}", ex);
+ }
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddleware.cs b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddleware.cs
new file mode 100644
index 000000000..b7fc8f8ff
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddleware.cs
@@ -0,0 +1,112 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Util.Ui.Razor;
+using Util.Ui.Sources.Spa.Npm;
+using Util.Ui.Sources.Spa.Proxying;
+using Util.Ui.Sources.Spa.Util;
+
+namespace Util.Ui.Sources.Spa.AngularCli;
+
+internal static class AngularCliMiddleware {
+ private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices";
+ private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds( 5 ); // This is a development-time only feature, so a very long timeout is fine
+
+ public static void Attach(
+ ISpaBuilder spaBuilder,
+ string scriptName ) {
+ var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand;
+ var sourcePath = spaBuilder.Options.SourcePath;
+ var devServerPort = spaBuilder.Options.DevServerPort;
+ if ( string.IsNullOrEmpty( sourcePath ) ) {
+ throw new ArgumentException( "Property 'SourcePath' cannot be null or empty", nameof( spaBuilder ) );
+ }
+
+ if ( string.IsNullOrEmpty( scriptName ) ) {
+ throw new ArgumentException( "Cannot be null or empty", nameof( scriptName ) );
+ }
+
+ if ( devServerPort == default ) {
+ devServerPort = TcpPortFinder.FindAvailablePort();
+ }
+
+ // Start Angular CLI and attach to middleware pipeline
+ var appBuilder = spaBuilder.ApplicationBuilder;
+ var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService().ApplicationStopping;
+ var logger = LoggerFinder.GetOrCreateLogger( appBuilder, LogCategoryName );
+ var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService();
+ var angularCliServerInfoTask = GetUrl( devServerPort, applicationStoppingToken );
+ spaBuilder.UseProxyToSpaDevelopmentServer( () => {
+ var timeout = spaBuilder.Options.StartupTimeout;
+ return angularCliServerInfoTask.WithTimeout( timeout,
+ $"The Angular CLI process did not start listening for requests " +
+ $"within the timeout period of {timeout.TotalSeconds} seconds. " +
+ $"Check the log output for error information." );
+ } );
+ Task.Factory.StartNew( async () => {
+ while ( true ) {
+ if ( RazorWatchService.IsStartComplete == false ) {
+ await Task.Delay( 500, applicationStoppingToken );
+ continue;
+ }
+ await StartAngularCliServerAsync( sourcePath, scriptName, pkgManagerCommand, devServerPort, logger, diagnosticSource, applicationStoppingToken );
+ return;
+ }
+ }, applicationStoppingToken );
+ }
+
+ private static async Task GetUrl( int portNumber, CancellationToken applicationStoppingToken ) {
+ var uri = new Uri( $"http://localhost:{portNumber}" );
+ await WaitForAngularCliServerToAcceptRequests( uri, applicationStoppingToken );
+ return uri;
+ }
+
+ private static async Task StartAngularCliServerAsync( string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken ) {
+ Console.WriteLine($"dbug: Angular http://localhost:{portNumber}");
+ if ( logger.IsEnabled( LogLevel.Information ) ) {
+ logger.LogInformation( $"Starting @angular/cli on port {portNumber}..." );
+ }
+
+ var scriptRunner = new NodeScriptRunner(
+ sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand, diagnosticSource, applicationStoppingToken );
+ scriptRunner.AttachToLogger( logger );
+
+ bool openBrowserLine;
+ using var stdErrReader = new EventedStreamStringReader( scriptRunner.StdErr );
+ try {
+ openBrowserLine = await scriptRunner.StdOut.WaitForMatch( ["Watch mode enabled", "Angular Live Development"] );
+ }
+ catch ( EndOfStreamException ex ) {
+ throw new InvalidOperationException(
+ $"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " +
+ $"Angular CLI was listening for requests. The error output was: " +
+ $"{stdErrReader.ReadAsString()}", ex );
+ }
+ }
+
+ private static async Task WaitForAngularCliServerToAcceptRequests( Uri cliServerUri, CancellationToken applicationStoppingToken ) {
+ using var client = new HttpClient();
+ var i = 0;
+ while ( true ) {
+ try {
+ i++;
+ var response = await client.SendAsync( new HttpRequestMessage( HttpMethod.Get, cliServerUri ), applicationStoppingToken );
+ var content = await response.Content.ReadAsStringAsync( applicationStoppingToken );
+ if ( content.IsEmpty() == false ) {
+ return;
+ }
+ await Task.Delay( 300, applicationStoppingToken );
+ if ( i > 1000 ) {
+ return;
+ }
+ }
+ catch ( Exception ) {
+ // ignored
+ }
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddlewareExtensions.cs
new file mode 100644
index 000000000..e844569ad
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddlewareExtensions.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa.AngularCli;
+
+///
+/// Extension methods for enabling Angular CLI middleware support.
+///
+public static class AngularCliMiddlewareExtensions {
+ ///
+ /// ʹAngular
+ ///
+ public static void UseAngularCliServer(
+ this ISpaBuilder spaBuilder,
+ string npmScript ) {
+ ArgumentNullException.ThrowIfNull( spaBuilder );
+
+ var spaOptions = spaBuilder.Options;
+
+ if ( string.IsNullOrEmpty( spaOptions.SourcePath ) ) {
+ throw new InvalidOperationException( $"To use {nameof( UseAngularCliServer )}, you must supply a non-empty value for the {nameof( SpaOptions.SourcePath )} property of {nameof( SpaOptions )} ." );
+ }
+ AngularCliMiddleware.Attach( spaBuilder, npmScript );
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/DefaultSpaBuilder.cs b/src/Util.Ui/Sources/Spa/DefaultSpaBuilder.cs
new file mode 100644
index 000000000..522001091
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/DefaultSpaBuilder.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa;
+
+internal sealed class DefaultSpaBuilder : ISpaBuilder
+{
+ public IApplicationBuilder ApplicationBuilder { get; }
+
+ public SpaOptions Options { get; }
+
+ public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, SpaOptions options)
+ {
+ ApplicationBuilder = applicationBuilder
+ ?? throw new ArgumentNullException(nameof(applicationBuilder));
+
+ Options = options
+ ?? throw new ArgumentNullException(nameof(options));
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/ISpaBuilder.cs b/src/Util.Ui/Sources/Spa/ISpaBuilder.cs
new file mode 100644
index 000000000..aeb5df0d9
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/ISpaBuilder.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa;
+
+///
+/// Defines a class that provides mechanisms for configuring the hosting
+/// of a Single Page Application (SPA) and attaching middleware.
+///
+public interface ISpaBuilder
+{
+ ///
+ /// The representing the middleware pipeline
+ /// in which the SPA is being hosted.
+ ///
+ IApplicationBuilder ApplicationBuilder { get; }
+
+ ///
+ /// Describes configuration options for hosting a SPA.
+ ///
+ SpaOptions Options { get; }
+}
diff --git a/src/Util.Ui/Sources/Spa/Npm/NodeScriptRunner.cs b/src/Util.Ui/Sources/Spa/Npm/NodeScriptRunner.cs
new file mode 100644
index 000000000..cd330459a
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Npm/NodeScriptRunner.cs
@@ -0,0 +1,163 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+using Microsoft.Extensions.Logging;
+using Util.Ui.Sources.Spa.Util;
+
+// This is under the NodeServices namespace because post 2.1 it will be moved to that package
+namespace Util.Ui.Sources.Spa.Npm;
+
+///
+/// Executes the script entries defined in a package.json file,
+/// capturing any output written to stdio.
+///
+internal sealed class NodeScriptRunner : IDisposable
+{
+ private Process _npmProcess;
+ public EventedStreamReader StdOut { get; }
+ public EventedStreamReader StdErr { get; }
+
+ private static readonly Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1));
+
+ public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars, string pkgManagerCommand, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken)
+ {
+ if (string.IsNullOrEmpty(workingDirectory))
+ {
+ throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory));
+ }
+
+ if (string.IsNullOrEmpty(scriptName))
+ {
+ throw new ArgumentException("Cannot be null or empty.", nameof(scriptName));
+ }
+
+ if (string.IsNullOrEmpty(pkgManagerCommand))
+ {
+ throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerCommand));
+ }
+
+ var exeToRun = pkgManagerCommand;
+ var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}";
+ if (OperatingSystem.IsWindows())
+ {
+ // On Windows, the node executable is a .cmd file, so it can't be executed
+ // directly (except with UseShellExecute=true, but that's no good, because
+ // it prevents capturing stdio). So we need to invoke it via "cmd /c".
+ exeToRun = "cmd";
+ completeArguments = $"/c {pkgManagerCommand} {completeArguments}";
+ }
+
+ var processStartInfo = new ProcessStartInfo(exeToRun)
+ {
+ Arguments = completeArguments,
+ UseShellExecute = false,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ WorkingDirectory = workingDirectory
+ };
+
+ if (envVars != null)
+ {
+ foreach (var keyValuePair in envVars)
+ {
+ processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value;
+ }
+ }
+
+ _npmProcess = LaunchNodeProcess(processStartInfo, pkgManagerCommand);
+ StdOut = new EventedStreamReader(_npmProcess.StandardOutput);
+ StdErr = new EventedStreamReader(_npmProcess.StandardError);
+
+ applicationStoppingToken.Register(((IDisposable)this).Dispose);
+
+ if (diagnosticSource.IsEnabled("Microsoft.AspNetCore.NodeServices.Npm.NpmStarted"))
+ {
+ WriteDiagnosticEvent(
+ diagnosticSource,
+ "Microsoft.AspNetCore.NodeServices.Npm.NpmStarted",
+ new
+ {
+ processStartInfo = processStartInfo,
+ process = _npmProcess
+ });
+ }
+
+ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026",
+ Justification = "The values being passed into Write have the commonly used properties being preserved with DynamicDependency.")]
+ static void WriteDiagnosticEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(DiagnosticSource diagnosticSource, string name, TValue value)
+ => diagnosticSource.Write(name, value);
+ }
+
+ public void AttachToLogger(ILogger logger)
+ {
+ // When the node task emits complete lines, pass them through to the real logger
+ StdOut.OnReceivedLine += line =>
+ {
+ if (!string.IsNullOrWhiteSpace(line) && logger.IsEnabled(LogLevel.Information))
+ {
+ // Node tasks commonly emit ANSI colors, but it wouldn't make sense to forward
+ // those to loggers (because a logger isn't necessarily any kind of terminal)
+ logger.LogInformation(StripAnsiColors(line));
+ }
+ };
+
+ StdErr.OnReceivedLine += line =>
+ {
+ if (!string.IsNullOrWhiteSpace(line))
+ {
+ logger.LogWarning(StripAnsiColors(line));
+ }
+ };
+
+ // But when it emits incomplete lines, assume this is progress information and
+ // hence just pass it through to StdOut regardless of logger config.
+ StdErr.OnReceivedChunk += chunk =>
+ {
+ Debug.Assert(chunk.Array != null);
+
+ var containsNewline = Array.IndexOf(
+ chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0;
+ if (!containsNewline)
+ {
+ Console.Write(chunk.Array, chunk.Offset, chunk.Count);
+ }
+ };
+ }
+
+ private static string StripAnsiColors(string line)
+ => AnsiColorRegex.Replace(line, string.Empty);
+
+ private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName)
+ {
+ try
+ {
+ var process = Process.Start(startInfo)!;
+
+ // See equivalent comment in OutOfProcessNodeInstance.cs for why
+ process.EnableRaisingEvents = true;
+
+ return process;
+ }
+ catch (Exception ex)
+ {
+ var message = $"Failed to start '{commandName}'. To resolve this:.\n\n"
+ + $"[1] Ensure that '{commandName}' is installed and can be found in one of the PATH directories.\n"
+ + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n"
+ + " Make sure the executable is in one of those directories, or update your PATH.\n\n"
+ + "[2] See the InnerException for further details of the cause.";
+ throw new InvalidOperationException(message, ex);
+ }
+ }
+
+ void IDisposable.Dispose()
+ {
+ if (_npmProcess != null && !_npmProcess.HasExited)
+ {
+ _npmProcess.Kill(entireProcessTree: true);
+ _npmProcess = null;
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Prerendering/ISpaPrerendererBuilder.cs b/src/Util.Ui/Sources/Spa/Prerendering/ISpaPrerendererBuilder.cs
new file mode 100644
index 000000000..c9488694c
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Prerendering/ISpaPrerendererBuilder.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa.Prerendering;
+
+///
+/// Represents the ability to build a Single Page Application (SPA) on demand
+/// so that it can be prerendered. This is only intended to be used at development
+/// time. In production, a SPA should already have been built during publishing.
+///
+[Obsolete("Prerendering is no longer supported out of box")]
+public interface ISpaPrerendererBuilder
+{
+ ///
+ /// Builds the Single Page Application so that a JavaScript entrypoint file
+ /// exists on disk. Prerendering middleware can then execute that file in
+ /// a Node environment.
+ ///
+ /// The .
+ /// A representing completion of the build process.
+ Task Build(ISpaBuilder spaBuilder);
+}
diff --git a/src/Util.Ui/Sources/Spa/Proxying/ConditionalProxyMiddleware.cs b/src/Util.Ui/Sources/Spa/Proxying/ConditionalProxyMiddleware.cs
new file mode 100644
index 000000000..825d2988c
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Proxying/ConditionalProxyMiddleware.cs
@@ -0,0 +1,62 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Hosting;
+
+namespace Util.Ui.Sources.Spa.Proxying;
+
+// This duplicates and updates the proxying logic in SpaServices so that we can update
+// the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship,
+// merge the additional proxying features (e.g., proxying websocket connections) back
+// into the SpaServices proxying code. It's all internal.
+internal sealed class ConditionalProxyMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly Task _baseUriTask;
+ private readonly string _pathPrefix;
+ private readonly bool _pathPrefixIsRoot;
+ private readonly HttpClient _httpClient;
+ private readonly CancellationToken _applicationStoppingToken;
+
+ public ConditionalProxyMiddleware(
+ RequestDelegate next,
+ string pathPrefix,
+ TimeSpan requestTimeout,
+ Task baseUriTask,
+ IHostApplicationLifetime applicationLifetime)
+ {
+ if (!pathPrefix.StartsWith('/'))
+ {
+ pathPrefix = "/" + pathPrefix;
+ }
+
+ _next = next;
+ _pathPrefix = pathPrefix;
+ _pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal);
+ _baseUriTask = baseUriTask;
+ _httpClient = SpaProxy.CreateHttpClientForProxy(requestTimeout);
+ _applicationStoppingToken = applicationLifetime.ApplicationStopping;
+ }
+
+ public Task Invoke(HttpContext context)
+ {
+ if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot)
+ {
+ return InvokeCore(context);
+ }
+ return _next.Invoke(context);
+ }
+
+ private async Task InvokeCore(HttpContext context)
+ {
+ var didProxyRequest = await SpaProxy.PerformProxyRequest(
+ context, _httpClient, _baseUriTask, _applicationStoppingToken, proxy404s: false);
+ if (didProxyRequest)
+ {
+ return;
+ }
+
+ // Not a request we can proxy
+ await _next.Invoke(context);
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Proxying/SpaProxy.cs b/src/Util.Ui/Sources/Spa/Proxying/SpaProxy.cs
new file mode 100644
index 000000000..0b379c775
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Proxying/SpaProxy.cs
@@ -0,0 +1,304 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net.WebSockets;
+
+namespace Util.Ui.Sources.Spa.Proxying;
+
+// This duplicates and updates the proxying logic in SpaServices so that we can update
+// the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship,
+// remove the old ConditionalProxy.cs from SpaServices and replace its usages with this.
+// Doesn't affect public API surface - it's all internal.
+internal static class SpaProxy
+{
+ private const int DefaultWebSocketBufferSize = 4096;
+ private const int StreamCopyBufferSize = 81920;
+
+ // https://github.com/dotnet/aspnetcore/issues/16797
+ private static readonly HashSet NotForwardedHttpHeaders = new HashSet(
+ new[] { "Connection" },
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ // Don't forward User-Agent/Accept because of https://github.com/aspnet/JavaScriptServices/issues/1469
+ // Others just aren't applicable in proxy scenarios
+ private static readonly HashSet NotForwardedWebSocketHeaders = new HashSet(
+ new[] { "Accept", "Connection", "Host", "User-Agent", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Protocol", "Sec-WebSocket-Version" },
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ // In case the connection to the client is HTTP/2 or HTTP/3 and to the server HTTP/1.1 or less, let's get rid of the HTTP/1.1 only headers
+ private static readonly HashSet InvalidH2H3Headers = new HashSet(
+ new[] { "Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection" },
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ public static HttpClient CreateHttpClientForProxy(TimeSpan requestTimeout)
+ {
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ };
+
+ return new HttpClient(handler)
+ {
+ Timeout = requestTimeout
+ };
+ }
+
+ public static async Task PerformProxyRequest(
+ HttpContext context,
+ HttpClient httpClient,
+ Task baseUriTask,
+ CancellationToken applicationStoppingToken,
+ bool proxy404s)
+ {
+ // Stop proxying if either the server or client wants to disconnect
+ var proxyCancellationToken = CancellationTokenSource.CreateLinkedTokenSource(
+ context.RequestAborted,
+ applicationStoppingToken).Token;
+
+ // We allow for the case where the target isn't known ahead of time, and want to
+ // delay proxied requests until the target becomes known. This is useful, for example,
+ // when proxying to Angular CLI middleware: we won't know what port it's listening
+ // on until it finishes starting up.
+ var baseUri = await baseUriTask;
+ var baseUriAsString = baseUri.ToString();
+ var targetUri = new Uri((baseUriAsString.EndsWith("/", StringComparison.OrdinalIgnoreCase) ? baseUriAsString[..^1] : baseUriAsString)
+ + context.Request.Path
+ + context.Request.QueryString);
+
+ try
+ {
+ if (context.WebSockets.IsWebSocketRequest)
+ {
+ await AcceptProxyWebSocketRequest(context, ToWebSocketScheme(targetUri), proxyCancellationToken);
+ return true;
+ }
+ else {
+ using var requestMessage = CreateProxyHttpRequest(context, targetUri);
+ using var responseMessage = await httpClient.SendAsync(
+ requestMessage,
+ HttpCompletionOption.ResponseHeadersRead,
+ proxyCancellationToken);
+ if (!proxy404s)
+ {
+ if (responseMessage.StatusCode == HttpStatusCode.NotFound)
+ {
+ // We're not proxying 404s, i.e., we want to resume the middleware pipeline
+ // and let some other middleware handle this.
+ return false;
+ }
+ }
+
+ await CopyProxyHttpResponse(context, responseMessage, proxyCancellationToken);
+ return true;
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // If we're aborting because either the client disconnected, or the server
+ // is shutting down, don't treat this as an error.
+ return true;
+ }
+ catch (IOException)
+ {
+ // This kind of exception can also occur if a proxy read/write gets interrupted
+ // due to the process shutting down.
+ return true;
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new HttpRequestException(
+ $"Failed to proxy the request to {targetUri.ToString()}, because the request to " +
+ $"the proxy target failed. Check that the proxy target server is running and " +
+ $"accepting requests to {baseUri.ToString()}.\n\n" +
+ $"The underlying exception message was '{ex.Message}'." +
+ $"Check the InnerException for more details.", ex);
+ }
+ }
+
+ private static HttpRequestMessage CreateProxyHttpRequest(HttpContext context, Uri uri)
+ {
+ var request = context.Request;
+
+ var requestMessage = new HttpRequestMessage();
+ var requestMethod = request.Method;
+ if (!HttpMethods.IsGet(requestMethod) &&
+ !HttpMethods.IsHead(requestMethod) &&
+ !HttpMethods.IsDelete(requestMethod) &&
+ !HttpMethods.IsTrace(requestMethod))
+ {
+ var streamContent = new StreamContent(request.Body);
+ requestMessage.Content = streamContent;
+ }
+
+ // Copy the request headers
+ foreach (var header in request.Headers)
+ {
+ if (NotForwardedHttpHeaders.Contains(header.Key))
+ {
+ continue;
+ }
+
+ if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()) && requestMessage.Content != null)
+ {
+ requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray());
+ }
+ }
+
+ requestMessage.Headers.Host = uri.Authority;
+ requestMessage.RequestUri = uri;
+ requestMessage.Method = new HttpMethod(request.Method);
+
+ return requestMessage;
+ }
+
+ private static async Task CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken)
+ {
+ context.Response.StatusCode = (int)responseMessage.StatusCode;
+ foreach (var header in responseMessage.Headers)
+ {
+ if ((HttpProtocol.IsHttp2(context.Request.Protocol) || HttpProtocol.IsHttp3(context.Request.Protocol))
+ && InvalidH2H3Headers.Contains(header.Key))
+ {
+ continue;
+ }
+ context.Response.Headers[header.Key] = header.Value.ToArray();
+ }
+
+ foreach (var header in responseMessage.Content.Headers)
+ {
+ context.Response.Headers[header.Key] = header.Value.ToArray();
+ }
+
+ // SendAsync removes chunking from the response. This removes the header so it doesn't expect a chunked response.
+ context.Response.Headers.Remove("transfer-encoding");
+
+ using (var responseStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken))
+ {
+ await responseStream.CopyToAsync(context.Response.Body, StreamCopyBufferSize, cancellationToken);
+ }
+ }
+
+ private static Uri ToWebSocketScheme(Uri uri)
+ {
+ ArgumentNullException.ThrowIfNull(uri);
+
+ var uriBuilder = new UriBuilder(uri);
+ if (string.Equals(uriBuilder.Scheme, "https", StringComparison.OrdinalIgnoreCase))
+ {
+ uriBuilder.Scheme = "wss";
+ }
+ else if (string.Equals(uriBuilder.Scheme, "http", StringComparison.OrdinalIgnoreCase))
+ {
+ uriBuilder.Scheme = "ws";
+ }
+
+ return uriBuilder.Uri;
+ }
+
+ private static async Task AcceptProxyWebSocketRequest(HttpContext context, Uri destinationUri, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+ ArgumentNullException.ThrowIfNull(destinationUri);
+
+ using (var client = new ClientWebSocket())
+ {
+ foreach (var protocol in context.WebSockets.WebSocketRequestedProtocols)
+ {
+ client.Options.AddSubProtocol(protocol);
+ }
+ foreach (var headerEntry in context.Request.Headers)
+ {
+ if (!NotForwardedWebSocketHeaders.Contains(headerEntry.Key))
+ {
+ try
+ {
+ client.Options.SetRequestHeader(headerEntry.Key, headerEntry.Value);
+ }
+ catch (ArgumentException)
+ {
+ // On net462, certain header names are reserved and can't be set.
+ // We filter out the known ones via the test above, but there could
+ // be others arbitrarily set by the client. It's not helpful to
+ // consider it an error, so just skip non-forwardable headers.
+ // The perf implications of handling this via a catch aren't an
+ // issue since this is a dev-time only feature.
+ }
+ }
+ }
+
+ try
+ {
+ // Note that this is not really good enough to make Websockets work with
+ // Angular CLI middleware. For some reason, ConnectAsync takes over 1 second,
+ // on Windows, by which time the logic in SockJS has already timed out and made
+ // it fall back on some other transport (xhr_streaming, usually). It's fine
+ // on Linux though, completing almost instantly.
+ //
+ // The slowness on Windows does not cause a problem though, because the transport
+ // fallback logic works correctly and doesn't surface any errors, but it would be
+ // better if ConnectAsync was fast enough and the initial Websocket transport
+ // could actually be used.
+ await client.ConnectAsync(destinationUri, cancellationToken);
+ }
+ catch (WebSocketException)
+ {
+ context.Response.StatusCode = 400;
+ return false;
+ }
+
+ using var server = await context.WebSockets.AcceptWebSocketAsync(client.SubProtocol);
+ var bufferSize = DefaultWebSocketBufferSize;
+ await Task.WhenAll(
+ PumpWebSocket(client, server, bufferSize, cancellationToken),
+ PumpWebSocket(server, client, bufferSize, cancellationToken));
+
+ return true;
+ }
+ }
+
+ private static async Task PumpWebSocket(WebSocket source, WebSocket destination, int bufferSize, CancellationToken cancellationToken)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize);
+
+ var buffer = new byte[bufferSize];
+
+ while (true)
+ {
+ // Because WebSocket.ReceiveAsync doesn't work well with CancellationToken (it doesn't
+ // actually exit when the token notifies, at least not in the 'server' case), use
+ // polling. The perf might not be ideal, but this is a dev-time feature only.
+ var resultTask = source.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+ while (true)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ if (resultTask.IsCompleted)
+ {
+ break;
+ }
+
+ await Task.Delay(100, cancellationToken);
+ }
+
+ var result = resultTask.Result; // We know it's completed already
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ if (destination.State == WebSocketState.Open || destination.State == WebSocketState.CloseReceived)
+ {
+ await destination.CloseOutputAsync(source.CloseStatus!.Value, source.CloseStatusDescription, cancellationToken);
+ }
+
+ return;
+ }
+
+ await destination.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken);
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Proxying/SpaProxyingExtensions.cs b/src/Util.Ui/Sources/Spa/Proxying/SpaProxyingExtensions.cs
new file mode 100644
index 000000000..281b2036d
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Proxying/SpaProxyingExtensions.cs
@@ -0,0 +1,84 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.Extensions.Hosting;
+
+namespace Util.Ui.Sources.Spa.Proxying;
+
+///
+/// Extension methods for proxying requests to a local SPA development server during
+/// development. Not for use in production applications.
+///
+public static class SpaProxyingExtensions {
+ ///
+ /// Configures the application to forward incoming requests to a local Single Page
+ /// Application (SPA) development server. This is only intended to be used during
+ /// development. Do not enable this middleware in production applications.
+ ///
+ /// The .
+ /// The target base URI to which requests should be proxied.
+ public static void UseProxyToSpaDevelopmentServer(
+ this ISpaBuilder spaBuilder,
+ string baseUri ) {
+ UseProxyToSpaDevelopmentServer(
+ spaBuilder,
+ new Uri( baseUri ) );
+ }
+
+ ///
+ /// Configures the application to forward incoming requests to a local Single Page
+ /// Application (SPA) development server. This is only intended to be used during
+ /// development. Do not enable this middleware in production applications.
+ ///
+ /// The .
+ /// The target base URI to which requests should be proxied.
+ public static void UseProxyToSpaDevelopmentServer(
+ this ISpaBuilder spaBuilder,
+ Uri baseUri ) {
+ UseProxyToSpaDevelopmentServer(
+ spaBuilder,
+ () => Task.FromResult( baseUri ) );
+ }
+
+ ///
+ /// Configures the application to forward incoming requests to a local Single Page
+ /// Application (SPA) development server. This is only intended to be used during
+ /// development. Do not enable this middleware in production applications.
+ ///
+ /// The .
+ /// A callback that will be invoked on each request to supply a that resolves with the target base URI to which requests should be proxied.
+ public static void UseProxyToSpaDevelopmentServer(
+ this ISpaBuilder spaBuilder,
+ Func> baseUriTaskFactory ) {
+ var applicationBuilder = spaBuilder.ApplicationBuilder;
+ var applicationStoppingToken = GetStoppingToken( applicationBuilder );
+
+ // Since we might want to proxy WebSockets requests (e.g., by default, AngularCliMiddleware
+ // requires it), enable it for the app
+ applicationBuilder.UseWebSockets();
+
+ // It's important not to time out the requests, as some of them might be to
+ // server-sent event endpoints or similar, where it's expected that the response
+ // takes an unlimited time and never actually completes
+ var neverTimeOutHttpClient =
+ SpaProxy.CreateHttpClientForProxy( Timeout.InfiniteTimeSpan );
+
+ applicationBuilder.Use(async(context, next) => {
+ if ( context.Request.Path.Value != null && context.Request.Path.Value.StartsWith( "/view/" ) ) {
+ await next();
+ return;
+ }
+ await SpaProxy.PerformProxyRequest(
+ context, neverTimeOutHttpClient, baseUriTaskFactory(), applicationStoppingToken,
+ proxy404s: true );
+ } );
+ }
+
+ private static CancellationToken GetStoppingToken( IApplicationBuilder appBuilder ) {
+ var applicationLifetime = appBuilder
+ .ApplicationServices
+ .GetRequiredService();
+ return applicationLifetime.ApplicationStopping;
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/SpaApplicationBuilderExtensions.cs b/src/Util.Ui/Sources/Spa/SpaApplicationBuilderExtensions.cs
new file mode 100644
index 000000000..554d2f35f
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/SpaApplicationBuilderExtensions.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa;
+
+///
+/// Provides extension methods used for configuring an application to
+/// host a client-side Single Page Application (SPA).
+///
+public static class SpaApplicationBuilderExtensions {
+ ///
+ /// Handles all requests from this point in the middleware chain by returning
+ /// the default page for the Single Page Application (SPA).
+ ///
+ /// This middleware should be placed late in the chain, so that other middleware
+ /// for serving static files, MVC actions, etc., takes precedence.
+ ///
+ /// The .
+ ///
+ /// This callback will be invoked so that additional middleware can be registered within
+ /// the context of this SPA.
+ ///
+ public static void UseAngular( this IApplicationBuilder app, Action configuration ) {
+ ArgumentNullException.ThrowIfNull( configuration );
+ var optionsProvider = app.ApplicationServices.GetService>()!;
+ var options = new SpaOptions( optionsProvider.Value );
+ var spaBuilder = new DefaultSpaBuilder( app, options );
+ configuration.Invoke( spaBuilder );
+ SpaDefaultPageMiddleware.Attach( spaBuilder );
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/SpaDefaultPageMiddleware.cs b/src/Util.Ui/Sources/Spa/SpaDefaultPageMiddleware.cs
new file mode 100644
index 000000000..be3d1de23
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/SpaDefaultPageMiddleware.cs
@@ -0,0 +1,51 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Hosting;
+using Util.Ui.Sources.Spa.StaticFiles;
+
+namespace Util.Ui.Sources.Spa;
+
+internal sealed class SpaDefaultPageMiddleware
+{
+ public static void Attach(ISpaBuilder spaBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(spaBuilder);
+
+ var app = spaBuilder.ApplicationBuilder;
+ var options = spaBuilder.Options;
+
+ // Rewrite all requests to the default page
+ app.Use((context, next) =>
+ {
+ // If we have an Endpoint, then this is a deferred match - just noop.
+ if (context.GetEndpoint() != null)
+ {
+ return next(context);
+ }
+
+ context.Request.Path = options.DefaultPage;
+ return next(context);
+ });
+
+ // Serve it as a static file
+ // Developers who need to host more than one SPA with distinct default pages can
+ // override the file provider
+ app.UseSpaStaticFilesInternal(
+ options.DefaultPageStaticFileOptions ?? new StaticFileOptions(),
+ allowFallbackOnServingWebRootFiles: true);
+
+ // If the default file didn't get served as a static file (usually because it was not
+ // present on disk), the SPA is definitely not going to work.
+ //app.Use( async (context, next) =>
+ //{
+ // // If we have an Endpoint, then this is a deferred match - just noop.
+ // if (context.GetEndpoint() != null)
+ // {
+ // await next(context);
+ // }
+ // await Task.Delay(100);
+ // await next( context );
+ //} );
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/SpaOptions.cs b/src/Util.Ui/Sources/Spa/SpaOptions.cs
new file mode 100644
index 000000000..17ca754b0
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/SpaOptions.cs
@@ -0,0 +1,99 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa;
+
+///
+/// Describes options for hosting a Single Page Application (SPA).
+///
+public class SpaOptions
+{
+ private PathString _defaultPage = "/index.html";
+ private string _packageManagerCommand = "npm";
+
+ ///
+ /// Constructs a new instance of .
+ ///
+ public SpaOptions()
+ {
+ }
+
+ ///
+ /// Constructs a new instance of .
+ ///
+ /// An instance of from which values should be copied.
+ internal SpaOptions(SpaOptions copyFromOptions)
+ {
+ _defaultPage = copyFromOptions.DefaultPage;
+ _packageManagerCommand = copyFromOptions.PackageManagerCommand;
+ DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions;
+ SourcePath = copyFromOptions.SourcePath;
+ DevServerPort = copyFromOptions.DevServerPort;
+ }
+
+ ///
+ /// Gets or sets the URL of the default page that hosts your SPA user interface.
+ /// The default value is "/index.html".
+ ///
+ public PathString DefaultPage
+ {
+ get => _defaultPage;
+ set
+ {
+ if (string.IsNullOrEmpty(value.Value))
+ {
+ throw new ArgumentException($"The value for {nameof(DefaultPage)} cannot be null or empty.");
+ }
+
+ _defaultPage = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the that supplies content
+ /// for serving the SPA's default page.
+ ///
+ /// If not set, a default file provider will read files from the
+ /// , which by default is
+ /// the wwwroot directory.
+ ///
+ public StaticFileOptions DefaultPageStaticFileOptions { get; set; }
+
+ ///
+ /// Gets or sets the path, relative to the application working directory,
+ /// of the directory that contains the SPA source files during
+ /// development. The directory may not exist in published applications.
+ ///
+ public string SourcePath { get; set; }
+
+ ///
+ /// Controls whether the development server should be used with a dynamic or fixed port.
+ ///
+ public int DevServerPort { get; set; }
+
+ ///
+ /// Gets or sets the name of the package manager executable, (e.g npm,
+ /// yarn) to run the SPA.
+ ///
+ /// The default value is 'npm'.
+ ///
+ public string PackageManagerCommand
+ {
+ get => _packageManagerCommand;
+ set
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new ArgumentException($"The value for {nameof(PackageManagerCommand)} cannot be null or empty.");
+ }
+
+ _packageManagerCommand = value;
+ }
+ }
+
+ ///
+ /// Gets or sets the maximum duration that a request will wait for the SPA
+ /// to become ready to serve to the client.
+ ///
+ public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(120);
+}
diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/DefaultSpaStaticFileProvider.cs b/src/Util.Ui/Sources/Spa/StaticFiles/DefaultSpaStaticFileProvider.cs
new file mode 100644
index 000000000..278bf54e2
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/StaticFiles/DefaultSpaStaticFileProvider.cs
@@ -0,0 +1,44 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.FileProviders;
+
+namespace Util.Ui.Sources.Spa.StaticFiles;
+
+///
+/// Provides an implementation of that supplies
+/// physical files at a location configured using .
+///
+internal sealed class DefaultSpaStaticFileProvider : ISpaStaticFileProvider
+{
+ private readonly IFileProvider _fileProvider;
+
+ public DefaultSpaStaticFileProvider(
+ IServiceProvider serviceProvider,
+ SpaStaticFilesOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (string.IsNullOrEmpty(options.RootPath))
+ {
+ throw new ArgumentException($"The {nameof(options.RootPath)} property " +
+ $"of {nameof(options)} cannot be null or empty.");
+ }
+
+ var env = serviceProvider.GetRequiredService();
+ var absoluteRootPath = Path.Combine(
+ env.ContentRootPath,
+ options.RootPath);
+
+ // PhysicalFileProvider will throw if you pass a non-existent path,
+ // but we don't want that scenario to be an error because for SPA
+ // scenarios, it's better if non-existing directory just means we
+ // don't serve any static files.
+ if (Directory.Exists(absoluteRootPath))
+ {
+ _fileProvider = new PhysicalFileProvider(absoluteRootPath);
+ }
+ }
+
+ public IFileProvider FileProvider => _fileProvider;
+}
diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/ISpaStaticFileProvider.cs b/src/Util.Ui/Sources/Spa/StaticFiles/ISpaStaticFileProvider.cs
new file mode 100644
index 000000000..bd5bb72c4
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/StaticFiles/ISpaStaticFileProvider.cs
@@ -0,0 +1,19 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.FileProviders;
+
+namespace Util.Ui.Sources.Spa.StaticFiles;
+
+///
+/// Represents a service that can provide static files to be served for a Single Page
+/// Application (SPA).
+///
+public interface ISpaStaticFileProvider
+{
+ ///
+ /// Gets the file provider, if available, that supplies the static files for the SPA.
+ /// The value is null if no file provider is available.
+ ///
+ IFileProvider FileProvider { get; }
+}
diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesExtensions.cs b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesExtensions.cs
new file mode 100644
index 000000000..3037390ad
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesExtensions.cs
@@ -0,0 +1,131 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.FileProviders;
+
+namespace Util.Ui.Sources.Spa.StaticFiles;
+
+///
+/// Extension methods for configuring an application to serve static files for a
+/// Single Page Application (SPA).
+///
+public static class SpaStaticFilesExtensions
+{
+ ///
+ /// Registers an service that can provide static
+ /// files to be served for a Single Page Application (SPA).
+ ///
+ /// The .
+ /// If specified, this callback will be invoked to set additional configuration options.
+ public static void AddSpaStaticFiles(
+ this IServiceCollection services,
+ Action configuration = null)
+ {
+ services.AddSingleton(serviceProvider =>
+ {
+ // Use the options configured in DI (or blank if none was configured)
+ var optionsProvider = serviceProvider.GetService>()!;
+ var options = optionsProvider.Value;
+
+ // Allow the developer to perform further configuration
+ configuration?.Invoke(options);
+
+ if (string.IsNullOrEmpty(options.RootPath))
+ {
+ throw new InvalidOperationException($"No {nameof(SpaStaticFilesOptions.RootPath)} " +
+ $"was set on the {nameof(SpaStaticFilesOptions)}.");
+ }
+
+ return new DefaultSpaStaticFileProvider(serviceProvider, options);
+ });
+ }
+
+ ///
+ /// Configures the application to serve static files for a Single Page Application (SPA).
+ /// The files will be located using the registered service.
+ ///
+ /// The .
+ public static void UseSpaStaticFiles(this IApplicationBuilder applicationBuilder)
+ {
+ UseSpaStaticFiles(applicationBuilder, new StaticFileOptions());
+ }
+
+ ///
+ /// Configures the application to serve static files for a Single Page Application (SPA).
+ /// The files will be located using the registered service.
+ ///
+ /// The .
+ /// Specifies options for serving the static files.
+ public static void UseSpaStaticFiles(this IApplicationBuilder applicationBuilder, StaticFileOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(applicationBuilder);
+ ArgumentNullException.ThrowIfNull(options);
+
+ UseSpaStaticFilesInternal(applicationBuilder,
+ staticFileOptions: options,
+ allowFallbackOnServingWebRootFiles: false);
+ }
+
+ internal static void UseSpaStaticFilesInternal(
+ this IApplicationBuilder app,
+ StaticFileOptions staticFileOptions,
+ bool allowFallbackOnServingWebRootFiles)
+ {
+ ArgumentNullException.ThrowIfNull(staticFileOptions);
+
+ // If the file provider was explicitly supplied, that takes precedence over any other
+ // configured file provider. This is most useful if the application hosts multiple SPAs
+ // (via multiple calls to UseSpa()), so each needs to serve its own separate static files
+ // instead of using AddSpaStaticFiles/UseSpaStaticFiles.
+ // But if no file provider was specified, try to get one from the DI config.
+ if (staticFileOptions.FileProvider == null)
+ {
+ var shouldServeStaticFiles = ShouldServeStaticFiles(
+ app,
+ allowFallbackOnServingWebRootFiles,
+ out var fileProviderOrDefault);
+ if (shouldServeStaticFiles)
+ {
+ staticFileOptions.FileProvider = fileProviderOrDefault;
+ }
+ else
+ {
+ // The registered ISpaStaticFileProvider says we shouldn't
+ // serve static files
+ return;
+ }
+ }
+
+ app.UseStaticFiles(staticFileOptions);
+ }
+
+ private static bool ShouldServeStaticFiles(
+ IApplicationBuilder app,
+ bool allowFallbackOnServingWebRootFiles,
+ out IFileProvider fileProviderOrDefault)
+ {
+ var spaStaticFilesService = app.ApplicationServices.GetService();
+ if (spaStaticFilesService != null)
+ {
+ // If an ISpaStaticFileProvider was configured but it says no IFileProvider is available
+ // (i.e., it supplies 'null'), this implies we should not serve any static files. This
+ // is typically the case in development when SPA static files are being served from a
+ // SPA development server (e.g., Angular CLI or create-react-app), in which case no
+ // directory of prebuilt files will exist on disk.
+ fileProviderOrDefault = spaStaticFilesService.FileProvider;
+ return fileProviderOrDefault != null;
+ }
+ else if (!allowFallbackOnServingWebRootFiles)
+ {
+ throw new InvalidOperationException($"To use {nameof(UseSpaStaticFiles)}, you must " +
+ $"first register an {nameof(ISpaStaticFileProvider)} in the service provider, typically " +
+ $"by calling services.{nameof(AddSpaStaticFiles)}.");
+ }
+ else
+ {
+ // Fall back on serving wwwroot
+ fileProviderOrDefault = null;
+ return true;
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesOptions.cs b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesOptions.cs
new file mode 100644
index 000000000..3f2152f81
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesOptions.cs
@@ -0,0 +1,20 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa.StaticFiles;
+
+///
+/// Represents options for serving static files for a Single Page Application (SPA).
+///
+public class SpaStaticFilesOptions
+{
+ ///
+ /// Gets or sets the path, relative to the application root, of the directory in which
+ /// the physical files are located.
+ ///
+ /// If the specified directory does not exist, then the
+ ///
+ /// middleware will not serve any static files.
+ ///
+ public string RootPath { get; set; } = default!;
+}
diff --git a/src/Util.Ui/Sources/Spa/Util/EventedStreamReader.cs b/src/Util.Ui/Sources/Spa/Util/EventedStreamReader.cs
new file mode 100644
index 000000000..2a7cabbbb
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Util/EventedStreamReader.cs
@@ -0,0 +1,130 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.RegularExpressions;
+
+namespace Util.Ui.Sources.Spa.Util;
+
+///
+/// Wraps a to expose an evented API, issuing notifications
+/// when the stream emits partial lines, completed lines, or finally closes.
+///
+internal sealed class EventedStreamReader
+{
+ public delegate void OnReceivedChunkHandler(ArraySegment chunk);
+ public delegate void OnReceivedLineHandler(string line);
+ public delegate void OnStreamClosedHandler();
+
+ public event OnReceivedChunkHandler OnReceivedChunk;
+ public event OnReceivedLineHandler OnReceivedLine;
+ public event OnStreamClosedHandler OnStreamClosed;
+
+ private readonly StreamReader _streamReader;
+ private readonly StringBuilder _linesBuffer;
+
+ public EventedStreamReader(StreamReader streamReader)
+ {
+ _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader));
+ _linesBuffer = new StringBuilder();
+ Task.Factory.StartNew(Run, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default);
+ }
+
+ public Task WaitForMatch(string[] contents)
+ {
+ var tcs = new TaskCompletionSource();
+ var completionLock = new object();
+
+ OnReceivedLineHandler onReceivedLineHandler = null;
+ OnStreamClosedHandler onStreamClosedHandler = null;
+
+ void ResolveIfStillPending(Action applyResolution)
+ {
+ lock (completionLock)
+ {
+ if (!tcs.Task.IsCompleted)
+ {
+ OnReceivedLine -= onReceivedLineHandler;
+ OnStreamClosed -= onStreamClosedHandler;
+ applyResolution();
+ }
+ }
+ }
+
+ onReceivedLineHandler = line => {
+
+ if ( contents.Any( content => line.Contains( content,StringComparison.OrdinalIgnoreCase ) ) ) {
+ Console.WriteLine( $"dbug: {line}" );
+ ResolveIfStillPending( () => tcs.SetResult( true ) );
+ }
+ };
+
+ onStreamClosedHandler = () =>
+ {
+ ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException()));
+ };
+
+ OnReceivedLine += onReceivedLineHandler;
+ OnStreamClosed += onStreamClosedHandler;
+
+ return tcs.Task;
+ }
+
+ private async Task Run()
+ {
+ var buf = new char[8 * 1024];
+ while (true)
+ {
+ var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length);
+ if (chunkLength == 0)
+ {
+ if (_linesBuffer.Length > 0)
+ {
+ OnCompleteLine(_linesBuffer.ToString());
+ _linesBuffer.Clear();
+ }
+
+ OnClosed();
+ break;
+ }
+
+ OnChunk(new ArraySegment(buf, 0, chunkLength));
+
+ int lineBreakPos;
+ var startPos = 0;
+
+ // get all the newlines
+ while ((lineBreakPos = Array.IndexOf(buf, '\n', startPos, chunkLength - startPos)) >= 0 && startPos < chunkLength)
+ {
+ var length = (lineBreakPos + 1) - startPos;
+ _linesBuffer.Append(buf, startPos, length);
+ OnCompleteLine(_linesBuffer.ToString());
+ _linesBuffer.Clear();
+ startPos = lineBreakPos + 1;
+ }
+
+ // get the rest
+ if (lineBreakPos < 0 && startPos < chunkLength)
+ {
+ _linesBuffer.Append(buf, startPos, chunkLength - startPos);
+ }
+ }
+ }
+
+ private void OnChunk(ArraySegment chunk)
+ {
+ var dlg = OnReceivedChunk;
+ dlg?.Invoke(chunk);
+ }
+
+ private void OnCompleteLine(string line)
+ {
+ var dlg = OnReceivedLine;
+ dlg?.Invoke(line);
+ }
+
+ private void OnClosed()
+ {
+ var dlg = OnStreamClosed;
+ dlg?.Invoke();
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Util/EventedStreamStringReader.cs b/src/Util.Ui/Sources/Spa/Util/EventedStreamStringReader.cs
new file mode 100644
index 000000000..ad5b7c5c2
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Util/EventedStreamStringReader.cs
@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa.Util;
+
+///
+/// Captures the completed-line notifications from a ,
+/// combining the data into a single .
+///
+internal sealed class EventedStreamStringReader : IDisposable
+{
+ private readonly EventedStreamReader _eventedStreamReader;
+ private bool _isDisposed;
+ private readonly StringBuilder _stringBuilder = new StringBuilder();
+
+ public EventedStreamStringReader(EventedStreamReader eventedStreamReader)
+ {
+ _eventedStreamReader = eventedStreamReader
+ ?? throw new ArgumentNullException(nameof(eventedStreamReader));
+ _eventedStreamReader.OnReceivedLine += OnReceivedLine;
+ }
+
+ public string ReadAsString() => _stringBuilder.ToString();
+
+ private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line);
+
+ public void Dispose()
+ {
+ if (!_isDisposed)
+ {
+ _eventedStreamReader.OnReceivedLine -= OnReceivedLine;
+ _isDisposed = true;
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Util/LoggerFinder.cs b/src/Util.Ui/Sources/Spa/Util/LoggerFinder.cs
new file mode 100644
index 000000000..10009b39a
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Util/LoggerFinder.cs
@@ -0,0 +1,22 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Util.Ui.Sources.Spa.Util;
+
+internal static class LoggerFinder
+{
+ public static ILogger GetOrCreateLogger(
+ IApplicationBuilder appBuilder,
+ string logCategoryName)
+ {
+ // If the DI system gives us a logger, use it. Otherwise, set up a default one
+ var loggerFactory = appBuilder.ApplicationServices.GetService();
+ var logger = loggerFactory != null
+ ? loggerFactory.CreateLogger(logCategoryName)
+ : NullLogger.Instance;
+ return logger;
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Util/TaskTimeoutExtensions.cs b/src/Util.Ui/Sources/Spa/Util/TaskTimeoutExtensions.cs
new file mode 100644
index 000000000..5de1b2585
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Util/TaskTimeoutExtensions.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa.Util;
+
+internal static class TaskTimeoutExtensions
+{
+ public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message)
+ {
+ if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
+ {
+ task.Wait(); // Allow any errors to propagate
+ }
+ else
+ {
+ throw new TimeoutException(message);
+ }
+ }
+
+ public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message)
+ {
+ if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay)))
+ {
+ return task.Result;
+ }
+ else
+ {
+ throw new TimeoutException(message);
+ }
+ }
+}
diff --git a/src/Util.Ui/Sources/Spa/Util/TcpPortFinder.cs b/src/Util.Ui/Sources/Spa/Util/TcpPortFinder.cs
new file mode 100644
index 000000000..ab9fc4c0a
--- /dev/null
+++ b/src/Util.Ui/Sources/Spa/Util/TcpPortFinder.cs
@@ -0,0 +1,21 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Util.Ui.Sources.Spa.Util;
+
+internal static class TcpPortFinder
+{
+ public static int FindAvailablePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ return ((IPEndPoint)listener.LocalEndpoint).Port;
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+}
diff --git a/src/Util.Ui/Usings.cs b/src/Util.Ui/Usings.cs
index 44c2a71ce..297c58d34 100644
--- a/src/Util.Ui/Usings.cs
+++ b/src/Util.Ui/Usings.cs
@@ -10,6 +10,14 @@
global using System.ComponentModel.DataAnnotations;
global using System.Text.Encodings.Web;
global using System.Threading;
+global using System.Text;
+global using System.Globalization;
+global using System.Net.Sockets;
+global using System.Net;
+global using System.Net.Http;
+global using Microsoft.AspNetCore.Hosting;
+global using Microsoft.AspNetCore.Http;
+global using Microsoft.AspNetCore.Builder;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.AspNetCore.Html;
global using Microsoft.AspNetCore.Razor.TagHelpers;
diff --git a/test/Util.Core.Tests/Helpers/UrlTest.cs b/test/Util.Core.Tests/Helpers/UrlTest.cs
index a46d4dd0f..7aed059f1 100644
--- a/test/Util.Core.Tests/Helpers/UrlTest.cs
+++ b/test/Util.Core.Tests/Helpers/UrlTest.cs
@@ -49,4 +49,36 @@ public void TestJoinPath_3() {
public void TestJoinPath_4() {
Assert.Equal( "a/c", Url.JoinPath( "a/b", "../c" ) );
}
+
+ ///
+ /// 测试连接路径 - http打头
+ ///
+ [Fact]
+ public void TestJoinPath_5() {
+ Assert.Equal( "http://a.com/a/c", Url.JoinPath( "http://a.com", "a/b", "../c" ) );
+ }
+
+ ///
+ /// 测试连接路径 - https打头
+ ///
+ [Fact]
+ public void TestJoinPath_6() {
+ Assert.Equal( "https://a.com/a/c", Url.JoinPath( "https://a.com/", "a/b/", "../c" ) );
+ }
+
+ ///
+ /// 测试连接路径 - https打头,带..路径
+ ///
+ [Fact]
+ public void TestJoinPath_7() {
+ Assert.Equal( "https://a.com/a/c/D/F/g", Url.JoinPath( "Https://a.com/a/b/", "../c/D/e/", "../F/g" ) );
+ }
+
+ ///
+ /// 测试连接路径 - https打头,带..路径
+ ///
+ [Fact]
+ public void TestJoinPath_8() {
+ Assert.Equal( "https://a.com/view/a", Url.JoinPath( "https://a.com", "view", "a" ) );
+ }
}
\ No newline at end of file
diff --git a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs
index 8b70a306d..fcd9daca4 100644
--- a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs
+++ b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs
@@ -104,5 +104,19 @@ public void TestFor_4() {
result.Append( "{{model.birthday|date:\"yyyy-MM\"}}" );
Assert.Equal( result.ToString(), GetResult() );
}
+
+ ///
+ /// 测试属性表达式 - 数值类型
+ ///
+ [Fact]
+ public void TestFor_5() {
+ NgZorroOptionsService.SetOptions( new NgZorroOptions { EnableI18n = true } );
+ _wrapper.SetExpression( t => t.IdCard );
+ var result = new StringBuilder();
+ result.Append( "" );
+ result.Append( "{{model.idCard}}" );
+ result.Append( "" );
+ Assert.Equal( result.ToString(), GetResult() );
+ }
}
}
\ No newline at end of file
diff --git a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs
index 5c069795b..cd0edfba5 100644
--- a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs
+++ b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs
@@ -4,43 +4,43 @@
using Util.Ui.NgZorro.Configs;
using Xunit;
-namespace Util.Ui.NgZorro.Tests.Descriptions {
+namespace Util.Ui.NgZorro.Tests.Descriptions;
+
+///
+/// 描述列表项测试 - 多语言支持
+///
+public partial class DescriptionItemTagHelperTest {
///
- /// 描述列表项测试 - 多语言支持
+ /// 测试标题
///
- public partial class DescriptionItemTagHelperTest {
- ///
- /// 测试标题
- ///
- [Fact]
- public void TestTitle() {
- _wrapper.SetContextAttribute( UiConst.Title, "a" );
- var result = new StringBuilder();
- result.Append( "" );
- Assert.Equal( result.ToString(), GetResult() );
- }
+ [Fact]
+ public void TestTitle() {
+ _wrapper.SetContextAttribute( UiConst.Title, "a" );
+ var result = new StringBuilder();
+ result.Append( "" );
+ Assert.Equal( result.ToString(), GetResult() );
+ }
- ///
- /// 测试标题- 多语言
- ///
- [Fact]
- public void TestTitle_I18n() {
- NgZorroOptionsService.SetOptions( new NgZorroOptions { EnableI18n = true } );
- _wrapper.SetContextAttribute( UiConst.Title, "a" );
- var result = new StringBuilder();
- result.Append( "" );
- Assert.Equal( result.ToString(), GetResult() );
- }
+ ///
+ /// 测试标题- 多语言
+ ///
+ [Fact]
+ public void TestTitle_I18n() {
+ NgZorroOptionsService.SetOptions( new NgZorroOptions { EnableI18n = true } );
+ _wrapper.SetContextAttribute( UiConst.Title, "a" );
+ var result = new StringBuilder();
+ result.Append( "" );
+ Assert.Equal( result.ToString(), GetResult() );
+ }
- ///
- /// 测试标题
- ///
- [Fact]
- public void TestBindTitle() {
- _wrapper.SetContextAttribute( AngularConst.BindTitle, "a" );
- var result = new StringBuilder();
- result.Append( "" );
- Assert.Equal( result.ToString(), GetResult() );
- }
+ ///
+ /// 测试标题
+ ///
+ [Fact]
+ public void TestBindTitle() {
+ _wrapper.SetContextAttribute( AngularConst.BindTitle, "a" );
+ var result = new StringBuilder();
+ result.Append( "" );
+ Assert.Equal( result.ToString(), GetResult() );
}
}
\ No newline at end of file
diff --git a/test/Util.Ui.NgZorro.Tests/Startup.cs b/test/Util.Ui.NgZorro.Tests/Startup.cs
index 8310cb623..3bb9ce9ac 100644
--- a/test/Util.Ui.NgZorro.Tests/Startup.cs
+++ b/test/Util.Ui.NgZorro.Tests/Startup.cs
@@ -12,13 +12,14 @@ public class Startup {
///
public void ConfigureHost( IHostBuilder hostBuilder ) {
hostBuilder.ConfigureDefaults( null ).AddUtil();
+ Util.Helpers.Environment.IsTest = true;
}
- ///
- /// ÷
- ///
- public void ConfigureServices( IServiceCollection services ) {
- services.AddLogging( logBuilder => logBuilder.AddXunitOutput() );
- }
- }
+ ///
+ /// ÷
+ ///
+ public void ConfigureServices( IServiceCollection services ) {
+ services.AddLogging( logBuilder => logBuilder.AddXunitOutput() );
+ }
+ }
}
diff --git a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs
index 3514b9166..4d650187c 100644
--- a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs
+++ b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs
@@ -624,4 +624,21 @@ public void TestAcl() {
}
#endregion
+
+ #region Tooltip
+
+ ///
+ /// 测试提示文字
+ ///
+ [Fact]
+ public void TestTooltipTitle() {
+ _wrapper.SetContextAttribute( UiConst.Column, "a" );
+ _wrapper.AppendContent( "b" );
+ _wrapper.SetContextAttribute( UiConst.TooltipTitle, "a" );
+ var result = new StringBuilder();
+ result.Append( "b | " );
+ Assert.Equal( result.ToString(), GetResult() );
+ }
+
+ #endregion
}
\ No newline at end of file
diff --git a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs
index e068d87b4..e9322eec3 100644
--- a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs
+++ b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs
@@ -416,4 +416,15 @@ public void TestOnExpandChange() {
result.Append( " | " );
Assert.Equal( result.ToString(), GetResult() );
}
+
+ ///
+ /// 测试单击事件
+ ///
+ [Fact]
+ public void TestOnClick() {
+ _wrapper.SetContextAttribute( UiConst.OnClick, "a" );
+ var result = new StringBuilder();
+ result.Append( " | " );
+ Assert.Equal( result.ToString(), GetResult() );
+ }
}
\ No newline at end of file
diff --git a/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs b/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs
index fdb3433a1..a5b015b03 100644
--- a/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs
+++ b/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs
@@ -15,7 +15,6 @@ public partial class TableTagHelperTest {
///
[Fact]
public void TestEnableTableSettings_1() {
- //启用自定义列
_wrapper.SetContextAttribute( UiConst.Key, "k" );
_wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
@@ -56,7 +55,6 @@ public void TestEnableTableSettings_1() {
///
[Fact]
public void TestEnableTableSettings_2() {
- //启用自定义列
_wrapper.SetContextAttribute( UiConst.Key, "k" );
_wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
_wrapper.SetContextAttribute( UiConst.Size, TableSize.Small );
@@ -98,7 +96,6 @@ public void TestEnableTableSettings_2() {
///
[Fact]
public void TestEnableTableSettings_3() {
- //启用自定义列
_wrapper.SetContextAttribute( UiConst.Key, "k" );
_wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
_wrapper.SetContextAttribute( UiConst.ScrollHeight, "1" );
@@ -140,7 +137,6 @@ public void TestEnableTableSettings_3() {
///
[Fact]
public void TestEnableTableSettings_4() {
- //启用自定义列
_wrapper.SetContextAttribute( UiConst.Key, "k" );
_wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
_wrapper.SetContextAttribute( UiConst.ScrollWidth, "1" );
@@ -182,7 +178,6 @@ public void TestEnableTableSettings_4() {
///
[Fact]
public void TestEnableTableSettings_5() {
- //启用自定义列
_wrapper.SetContextAttribute( UiConst.Key, "k" );
_wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
_wrapper.SetContextAttribute( UiConst.Scroll, "{x:'1px',y:'1px'}" );
@@ -224,7 +219,6 @@ public void TestEnableTableSettings_5() {
///
[Fact]
public void TestEnableTableSettings_6() {
- //启用自定义列
_wrapper.SetContextAttribute( UiConst.Key, "k" );
_wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
_wrapper.SetContextAttribute( UiConst.Bordered, true );
@@ -404,4 +398,41 @@ public void TestEnableTableSettings_8() {
result.Append( "" );
Assert.Equal( result.ToString(), GetResult() );
}
+
+ ///
+ /// 测试启用表格设置 - 编辑列显示区域嵌套列
+ ///
+ [Fact]
+ public void TestEnableTableSettings_9() {
+ _wrapper.SetContextAttribute( UiConst.Id, "tb" );
+ _wrapper.SetContextAttribute( UiConst.Key, "k" );
+ _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true );
+
+ //添加列
+ var column = new TableColumnTagHelper().ToWrapper();
+ column.SetContextAttribute( UiConst.IsEdit, true );
+ column.SetContextAttribute( UiConst.Title, "a" );
+ column.SetContextAttribute( UiConst.Column, "name" );
+ _wrapper.AppendContent( column );
+
+ //添加显示区域
+ var display = new TableColumnDisplayTagHelper().ToWrapper();
+ var subColumn = new TableColumnTagHelper().ToWrapper();
+ subColumn.SetContextAttribute( UiConst.Title, "b" );
+ subColumn.SetContextAttribute( UiConst.Column, "c" );
+ display.AppendContent( subColumn );
+ column.AppendContent( display );
+
+ //添加控件区域
+ var control = new TableColumnControlTagHelper().ToWrapper();
+ control.AppendContent( "b" );
+ column.AppendContent( control );
+
+ //结果
+ var result = new StringBuilder();
+ result.Append( "" );
+ result.Append( "" );
+ Assert.EndsWith( result.ToString(), GetResult() );
+ }
}
\ No newline at end of file