From 84b28f9672ed9f75262f7f71c5a60d73f2b78282 Mon Sep 17 00:00:00 2001 From: chfei Date: Tue, 27 Oct 2026 14:35:03 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MAX.Core/Autofac/AutofaceMiddleware.cs | 85 --------- .../AutofacNotificationServiceProvider.cs | 15 ++ .../Core/MAX.Core/Infrastructure/Bootstrap.cs | 32 ---- .../MAX.Core/Infrastructure/IBootstrap.cs | 7 - .../Infrastructure/MediatorManager.cs | 17 +- Source/Core/MAX.Core/MAX.Core.csproj | 5 +- Source/Core/MAX.Core/Startup.cs | 29 +-- .../MAX.Framework/Interfaces/IFolderConfig.cs | 17 ++ Source/Core/MAX.Framework/Logger/NLogger.cs | 38 ++-- .../AutofacContainerExtension.cs | 22 ++- Source/Core/MAX.Framework/Startup.cs | 2 +- Source/Core/MAX.Notification/INotification.cs | 7 + .../MAX.Notification/INotificationHandler.cs | 41 +++++ .../INotificationPublisher.cs | 12 ++ Source/Core/MAX.Notification/IPublisher.cs | 28 +++ Source/Core/MAX.Notification/LICENSE.md | 10 ++ .../MAX.Notification/MAX.Notification.csproj | 11 ++ .../NotificationHandlerExecutor.cs | 8 + .../MAX.Notification/NotificationPublisher.cs | 67 +++++++ .../ForeachAwaitPublisher.cs | 25 +++ .../TaskWhenAllPublisher.cs | 29 +++ Source/Core/MAX.Notification/README.md | 67 +++++++ Source/Core/MAX.Notification/UPSTREAM.md | 25 +++ .../Wrappers/NotificationHandlerWrapper.cs | 32 ++++ .../Store.Management.Api/ApiClientOptions.cs | 19 ++ .../Authentication/AuthApi.cs | 67 +++++++ .../Authentication/TokenSession.cs | 16 ++ .../Store.Management.Api/Models/ApiResult.cs | 19 ++ Source/Store.Management.Api/README.md | 78 ++++++++ .../Store.Management.Api.csproj | 7 + Source/Store.Management.Api/StoreApiClient.cs | 29 +++ .../Transport/ApiException.cs | 18 ++ .../Transport/ApiTransport.cs | 82 +++++++++ Source/Store.Management.Api/Users/UserApi.cs | 38 ++++ .../Store.Management.Api/Users/UserModels.cs | 46 +++++ Source/Store.Management.Framework/Config.cs | 61 +++++++ .../Configs/FolderConfig.cs | 51 ++++++ .../Controls/DialogWindow.xaml.cs | 10 +- .../StringNullOrEmpty2VisibilityConverter.cs | 15 ++ .../FrameworkApplication.cs | 169 ++++++++++++++---- .../Resources/HandyControlBasic.xaml | 15 ++ .../Services/ApplicationLogService.cs | 60 +++++++ .../Services/SettingsServiceBase.cs | 99 ++++++++++ .../Store.Management.Framework.csproj | 12 +- .../Utilities/AsyncEvent.cs | 32 ++++ .../Utilities/DisposeAction.cs | 15 ++ Source/Store.Management/App.xaml | 25 ++- Source/Store.Management/App.xaml.cs | 50 +++--- Source/Store.Management/MainWindow.xaml | 25 +-- Source/Store.Management/MainWindow.xaml.cs | 26 +-- Source/Store.Management/NLog.config | 57 ++++++ .../Notifications/NotificationExample.cs | 54 ++++++ .../Notifications/ProductSavedNotification.cs | 6 + .../Services/DependencyProbes.cs | 4 + .../Store.Management/Store.Management.csproj | 8 +- .../FrameworkTestDialogViewModel.cs | 23 +++ .../ViewModels/FrameworkTestPageViewModel.cs | 97 ++++++++++ .../Views/FrameworkTestDialog.xaml | 17 ++ .../Views/FrameworkTestDialog.xaml.cs | 6 + .../Views/FrameworkTestPage.xaml | 61 +++++++ .../Views/FrameworkTestPage.xaml.cs | 6 + Store.Management.slnx | 7 + .../MAX.Core.SmokeTests.csproj | 10 ++ Tests/MAX.Core.SmokeTests/Program.cs | 54 ++++++ .../MAX.Notification.SmokeTests.csproj | 11 ++ Tests/MAX.Notification.SmokeTests/Program.cs | 86 +++++++++ .../Program.cs | 80 +++++++++ .../Store.Management.Api.SmokeTests.csproj | 4 + .../Program.cs | 63 +++++++ .../Store.Management.App.SmokeTests.csproj | 11 ++ .../Program.cs | 113 ++++++++++++ ...ore.Management.Framework.SmokeTests.csproj | 11 ++ 72 files changed, 2222 insertions(+), 282 deletions(-) delete mode 100644 Source/Core/MAX.Core/Autofac/AutofaceMiddleware.cs create mode 100644 Source/Core/MAX.Core/Infrastructure/AutofacNotificationServiceProvider.cs delete mode 100644 Source/Core/MAX.Core/Infrastructure/Bootstrap.cs delete mode 100644 Source/Core/MAX.Core/Infrastructure/IBootstrap.cs create mode 100644 Source/Core/MAX.Framework/Interfaces/IFolderConfig.cs create mode 100644 Source/Core/MAX.Notification/INotification.cs create mode 100644 Source/Core/MAX.Notification/INotificationHandler.cs create mode 100644 Source/Core/MAX.Notification/INotificationPublisher.cs create mode 100644 Source/Core/MAX.Notification/IPublisher.cs create mode 100644 Source/Core/MAX.Notification/LICENSE.md create mode 100644 Source/Core/MAX.Notification/MAX.Notification.csproj create mode 100644 Source/Core/MAX.Notification/NotificationHandlerExecutor.cs create mode 100644 Source/Core/MAX.Notification/NotificationPublisher.cs create mode 100644 Source/Core/MAX.Notification/NotificationPublishers/ForeachAwaitPublisher.cs create mode 100644 Source/Core/MAX.Notification/NotificationPublishers/TaskWhenAllPublisher.cs create mode 100644 Source/Core/MAX.Notification/README.md create mode 100644 Source/Core/MAX.Notification/UPSTREAM.md create mode 100644 Source/Core/MAX.Notification/Wrappers/NotificationHandlerWrapper.cs create mode 100644 Source/Store.Management.Api/ApiClientOptions.cs create mode 100644 Source/Store.Management.Api/Authentication/AuthApi.cs create mode 100644 Source/Store.Management.Api/Authentication/TokenSession.cs create mode 100644 Source/Store.Management.Api/Models/ApiResult.cs create mode 100644 Source/Store.Management.Api/README.md create mode 100644 Source/Store.Management.Api/Store.Management.Api.csproj create mode 100644 Source/Store.Management.Api/StoreApiClient.cs create mode 100644 Source/Store.Management.Api/Transport/ApiException.cs create mode 100644 Source/Store.Management.Api/Transport/ApiTransport.cs create mode 100644 Source/Store.Management.Api/Users/UserApi.cs create mode 100644 Source/Store.Management.Api/Users/UserModels.cs create mode 100644 Source/Store.Management.Framework/Config.cs create mode 100644 Source/Store.Management.Framework/Configs/FolderConfig.cs create mode 100644 Source/Store.Management.Framework/Converters/StringNullOrEmpty2VisibilityConverter.cs create mode 100644 Source/Store.Management.Framework/Resources/HandyControlBasic.xaml create mode 100644 Source/Store.Management.Framework/Services/ApplicationLogService.cs create mode 100644 Source/Store.Management.Framework/Services/SettingsServiceBase.cs create mode 100644 Source/Store.Management.Framework/Utilities/AsyncEvent.cs create mode 100644 Source/Store.Management.Framework/Utilities/DisposeAction.cs create mode 100644 Source/Store.Management/NLog.config create mode 100644 Source/Store.Management/Notifications/NotificationExample.cs create mode 100644 Source/Store.Management/Notifications/ProductSavedNotification.cs create mode 100644 Source/Store.Management/Services/DependencyProbes.cs create mode 100644 Source/Store.Management/ViewModels/FrameworkTestDialogViewModel.cs create mode 100644 Source/Store.Management/ViewModels/FrameworkTestPageViewModel.cs create mode 100644 Source/Store.Management/Views/FrameworkTestDialog.xaml create mode 100644 Source/Store.Management/Views/FrameworkTestDialog.xaml.cs create mode 100644 Source/Store.Management/Views/FrameworkTestPage.xaml create mode 100644 Source/Store.Management/Views/FrameworkTestPage.xaml.cs create mode 100644 Tests/MAX.Core.SmokeTests/MAX.Core.SmokeTests.csproj create mode 100644 Tests/MAX.Core.SmokeTests/Program.cs create mode 100644 Tests/MAX.Notification.SmokeTests/MAX.Notification.SmokeTests.csproj create mode 100644 Tests/MAX.Notification.SmokeTests/Program.cs create mode 100644 Tests/Store.Management.Api.SmokeTests/Program.cs create mode 100644 Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj create mode 100644 Tests/Store.Management.App.SmokeTests/Program.cs create mode 100644 Tests/Store.Management.App.SmokeTests/Store.Management.App.SmokeTests.csproj create mode 100644 Tests/Store.Management.Framework.SmokeTests/Program.cs create mode 100644 Tests/Store.Management.Framework.SmokeTests/Store.Management.Framework.SmokeTests.csproj diff --git a/Source/Core/MAX.Core/Autofac/AutofaceMiddleware.cs b/Source/Core/MAX.Core/Autofac/AutofaceMiddleware.cs deleted file mode 100644 index 3dc6506..0000000 --- a/Source/Core/MAX.Core/Autofac/AutofaceMiddleware.cs +++ /dev/null @@ -1,85 +0,0 @@ -using Autofac; -using MAX.Core.DependencyInjection; -using MAX.Core.Helpers; -using MAX.Core.Infrastructure; -using MAX.Core.Reflections; -using System; -using System.Collections.Generic; -using System.Linq; - -namespace MAX.Core.Autofac; - -public static class AutofaceMiddlewareExtensions -{ - public static Bootstrap UseAutofac(this Bootstrap engine) - { - engine.UseMiddleware(); - return engine; - } -} - -internal class AutofaceMiddleware : IBootstrap -{ - private ContainerBuilder _builder; - public void Build() - { - IocManager.Container = _builder.Build(); - } - - public void Use() - { - _builder = new ContainerBuilder(); - IocManager.ContainerBuilder = _builder; - RegisterDependencies(_builder); - } - - - /// - /// 注册IOC - /// - /// - /// - private void RegisterDependencies(ContainerBuilder builder) - { - #region 注册所有IOC接口 - - IEnumerable ListDependency = ReflectionHelper.GetInstancesByInterface(AppTypeFinder.Assemblies.ToArray()); - foreach (IDependencyRegistrar item in ListDependency) - { - item.Register(builder); - } - - - //ISingletonDependency - Type singtype = typeof(ISingletonDependency); - builder.RegisterAssemblyTypes(AppTypeFinder.Assemblies.ToArray()) - .Where(a => - singtype.IsAssignableFrom(a) - && a.IsClass - && !a.IsAbstract - && !typeof(INoDependency).IsAssignableFrom(a) - ) - .AsImplementedInterfaces() - .AsSelf() - .PropertiesAutowired(new CustomPropertySelector()) - .SingleInstance(); - - - //ITransientDependency - Type transienttype = typeof(ITransientDependency); - builder.RegisterAssemblyTypes(AppTypeFinder.Assemblies.ToArray()) - .Where(a => - transienttype.IsAssignableFrom(a) - && a.IsClass - && !a.IsAbstract - && !typeof(INoDependency).IsAssignableFrom(a) - ) - .AsImplementedInterfaces() - .AsSelf() - .PropertiesAutowired(new CustomPropertySelector()) - .InstancePerLifetimeScope(); - - - #endregion - } -} diff --git a/Source/Core/MAX.Core/Infrastructure/AutofacNotificationServiceProvider.cs b/Source/Core/MAX.Core/Infrastructure/AutofacNotificationServiceProvider.cs new file mode 100644 index 0000000..4a711b9 --- /dev/null +++ b/Source/Core/MAX.Core/Infrastructure/AutofacNotificationServiceProvider.cs @@ -0,0 +1,15 @@ +using Autofac; + +namespace MAX.Core.Infrastructure; + +/// +/// 将通知发布器的服务解析委托给当前 Autofac 作用域,无须引入额外适配包。 +/// +internal sealed class AutofacNotificationServiceProvider(ILifetimeScope scope) : IServiceProvider +{ + public object? GetService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType); + return scope.ResolveOptional(serviceType); + } +} diff --git a/Source/Core/MAX.Core/Infrastructure/Bootstrap.cs b/Source/Core/MAX.Core/Infrastructure/Bootstrap.cs deleted file mode 100644 index 9d401a6..0000000 --- a/Source/Core/MAX.Core/Infrastructure/Bootstrap.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; - -namespace MAX.Core.Infrastructure; - -public class Bootstrap//Bootstrap -{ - public Bootstrap() - { - - } - - private readonly List _listUse = []; - public static Bootstrap Create() - { - return new Bootstrap(); - } - - - public void UseMiddleware() where T : IBootstrap, new() - { - T t = new(); - t.Use(); - _listUse.Add(t); - } - public void Builder() - { - foreach (IBootstrap item in _listUse) - { - item.Build(); - } - } -} diff --git a/Source/Core/MAX.Core/Infrastructure/IBootstrap.cs b/Source/Core/MAX.Core/Infrastructure/IBootstrap.cs deleted file mode 100644 index ae02eb8..0000000 --- a/Source/Core/MAX.Core/Infrastructure/IBootstrap.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace MAX.Core.Infrastructure; - -public interface IBootstrap -{ - void Use(); - void Build(); -} \ No newline at end of file diff --git a/Source/Core/MAX.Core/Infrastructure/MediatorManager.cs b/Source/Core/MAX.Core/Infrastructure/MediatorManager.cs index e173fd6..aa1453e 100644 --- a/Source/Core/MAX.Core/Infrastructure/MediatorManager.cs +++ b/Source/Core/MAX.Core/Infrastructure/MediatorManager.cs @@ -1,18 +1,15 @@ -using MAX.Core.Autofac; -using MediatR; -using Microsoft.Win32; +using MAX.Core.Autofac; +using MAX.Notification; using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Reflection; -using System.Text; using System.Threading; using System.Threading.Tasks; namespace MAX.Core.Infrastructure; -public class MediatorManager : Mediator +public class MediatorManager : NotificationPublisher { private readonly DynamicNotificationHandlerRegistry _registry; @@ -24,11 +21,11 @@ public class MediatorManager : Mediator } } - public MediatorManager(ServiceFactory serviceFactory) : base(serviceFactory) + public MediatorManager(IServiceProvider serviceProvider) : base(serviceProvider) { _registry = new DynamicNotificationHandlerRegistry(); } - protected override async Task PublishCore(IEnumerable> allHandlers, INotification notification, CancellationToken cancellationToken) + protected override async Task PublishCore(IEnumerable allHandlers, INotification notification, CancellationToken cancellationToken) { await base.PublishCore(allHandlers, notification, cancellationToken); @@ -47,7 +44,7 @@ class DynamicNotificationHandlerRegistry public DynamicNotificationHandlerRegistry() { } - // Key: notification type, Value: 已注册的 handler 调用委托列表 + // 按通知类型保存动态注册的处理器调用委托。 private readonly ConcurrentDictionary>> _handlers = new(); @@ -82,7 +79,7 @@ class DynamicNotificationHandlerRegistry } } - /// Publish 时由 PluginAwareMediator 调用 + /// 发布通知时调用动态处理器。 public async Task Publish(INotification notification, CancellationToken cancellationToken) { var notificationType = notification.GetType(); diff --git a/Source/Core/MAX.Core/MAX.Core.csproj b/Source/Core/MAX.Core/MAX.Core.csproj index 66f7466..af11229 100644 --- a/Source/Core/MAX.Core/MAX.Core.csproj +++ b/Source/Core/MAX.Core/MAX.Core.csproj @@ -8,7 +8,10 @@ - + + + + diff --git a/Source/Core/MAX.Core/Startup.cs b/Source/Core/MAX.Core/Startup.cs index c466aec..ccd483b 100644 --- a/Source/Core/MAX.Core/Startup.cs +++ b/Source/Core/MAX.Core/Startup.cs @@ -1,4 +1,4 @@ -/*** +/*** * _ooOoo_ * o8888888o * 88" . "88 @@ -20,7 +20,7 @@ * * ............................................. * 佛祖保佑 永无BUG - * 快加工资 不改需求 + * 快加工资 不改需求 */ @@ -28,12 +28,8 @@ using Autofac; using MAX.Core.DependencyInjection; using MAX.Core.Infrastructure; using MAX.Core.Reflections; -using MediatR; -using System; -using System.Collections.Generic; +using MAX.Notification; using System.Reflection; -using System.Threading; -using System.Threading.Tasks; namespace MAX.Core; @@ -50,10 +46,13 @@ public class Startup : IStartup { builder.RegisterInstance(LazyServiceProvider.Instance).As().AsSelf().SingleInstance(); - #region 注册MediatR + #region 注册通知服务 Assembly[] assemblies = AppTypeFinder.Assemblies.ToArray(); - builder.RegisterType().As().AsSelf().SingleInstance(); + builder.Register(ctx => new MediatorManager( + new AutofacNotificationServiceProvider(ctx.Resolve()))) + .As().AsSelf().InstancePerLifetimeScope(); + builder.RegisterAssemblyTypes(assemblies) .AsClosedTypesOf(typeof(INotificationHandler<>)) @@ -61,18 +60,6 @@ public class Startup : IStartup .AsSelf() .InstancePerLifetimeScope(); - builder.RegisterAssemblyTypes(assemblies) - .AsClosedTypesOf(typeof(IRequestHandler<>)) - .AsImplementedInterfaces() - .AsSelf() - .InstancePerLifetimeScope(); - - builder.Register(ctx => - { - var c = ctx.Resolve(); - return t => c.Resolve(t); - }).SingleInstance(); - #endregion } } \ No newline at end of file diff --git a/Source/Core/MAX.Framework/Interfaces/IFolderConfig.cs b/Source/Core/MAX.Framework/Interfaces/IFolderConfig.cs new file mode 100644 index 0000000..a66b5af --- /dev/null +++ b/Source/Core/MAX.Framework/Interfaces/IFolderConfig.cs @@ -0,0 +1,17 @@ +using MAX.Core.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MAX.Framework.Interfaces +{ + public interface IFolderConfig : ISingletonDependency + { + string ConfigFolder { get; } + string DataFolder { get; } + string LoggingFolder { get; } + string CacheFolder { get; } + } +} diff --git a/Source/Core/MAX.Framework/Logger/NLogger.cs b/Source/Core/MAX.Framework/Logger/NLogger.cs index 578d8bc..69e4d81 100644 --- a/Source/Core/MAX.Framework/Logger/NLogger.cs +++ b/Source/Core/MAX.Framework/Logger/NLogger.cs @@ -1,28 +1,42 @@ -using MAX.Core.Infrastructure; +using MAX.Core.DependencyInjection; +using MAX.Core.Infrastructure; using MAX.Core.Logger; +using MAX.Framework.Interfaces; using NLog; +using NLog.Config; +using NLog.Targets; using System; using LLogLevel = MAX.Core.Logger.LogLevel; using NLogLevel = NLog.LogLevel; namespace MAX.Framework.Logger; -public class NLogger : SingleInstance, ILoggerFacade +public class NLogger : ILoggerFacade, ISingletonDependency { private readonly ILogger _log; - - private NLogger() + public static ILoggerFacade Instance => LazyServiceProvider.Instance.LazyGetService(); + public NLogger(IFolderConfig folderConfig) { - //Target target = new FileTarget("log1") - //{ - // FileName = $"{FolderConfig.LoggingFolder}/{DateTime.Now:yyyy-MM-dd}.log", - //}; - //LoggingConfiguration config = new(); - //config.AddRule(NLogLevel.Debug, NLogLevel.Fatal, target); - - //LogManager.Configuration = config; + ArgumentNullException.ThrowIfNull(folderConfig); + ArgumentException.ThrowIfNullOrWhiteSpace(folderConfig.LoggingFolder); + lock (ConfigurationLock) + { + // 使用进程上下文,让 XML 配置及其自动重载共用宿主的目录约定。 + GlobalDiagnosticsContext.Set("LoggingFolder", folderConfig.LoggingFolder); + if (LogManager.Configuration == null) + { + var target = new FileTarget("application") + { + FileName = System.IO.Path.Combine(folderConfig.LoggingFolder, "${shortdate}.log"), + }; + var config = new LoggingConfiguration(); + config.AddRule(NLogLevel.Debug, NLogLevel.Fatal, target); + LogManager.Configuration = config; + } + } _log = LogManager.GetLogger("GlobalLogger"); } + private static readonly object ConfigurationLock = new(); public bool IsDebugEnabled { get; private set; } = true; diff --git a/Source/Core/MAX.Framework/PrismExtension/Prism.Autofac/AutofacContainerExtension.cs b/Source/Core/MAX.Framework/PrismExtension/Prism.Autofac/AutofacContainerExtension.cs index cfa2cac..5c7e93a 100644 --- a/Source/Core/MAX.Framework/PrismExtension/Prism.Autofac/AutofacContainerExtension.cs +++ b/Source/Core/MAX.Framework/PrismExtension/Prism.Autofac/AutofacContainerExtension.cs @@ -150,12 +150,18 @@ public class AutofacContainerExtension : IContainerExtension, IConta public IContainerRegistry RegisterSingleton(Type type, Func factoryMethod) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(factoryMethod); + Builder.Register(_ => factoryMethod()).As(type).SingleInstance(); + return this; } public IContainerRegistry RegisterSingleton(Type type, Func factoryMethod) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(factoryMethod); + Builder.Register(_ => factoryMethod(this)).As(type).SingleInstance(); + return this; } public object Resolve(Type type) @@ -192,12 +198,18 @@ public class AutofacContainerExtension : IContainerExtension, IConta public IContainerRegistry Register(Type type, Func factoryMethod) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(factoryMethod); + Builder.Register(_ => factoryMethod()).As(type); + return this; } public IContainerRegistry Register(Type type, Func factoryMethod) { - throw new NotImplementedException(); + ArgumentNullException.ThrowIfNull(type); + ArgumentNullException.ThrowIfNull(factoryMethod); + Builder.Register(_ => factoryMethod(this)).As(type); + return this; } public IContainerRegistry RegisterMany(Type type, params Type[] serviceTypes) { @@ -320,4 +332,4 @@ public class AutofacContainerExtension : IContainerExtension, IConta } } -} \ No newline at end of file +} diff --git a/Source/Core/MAX.Framework/Startup.cs b/Source/Core/MAX.Framework/Startup.cs index 1a624e3..24690b8 100644 --- a/Source/Core/MAX.Framework/Startup.cs +++ b/Source/Core/MAX.Framework/Startup.cs @@ -24,7 +24,7 @@ public class Startup : IStartup public void Register(ContainerBuilder builder) { - builder.RegisterInstance(NLogger.Instance).As().SingleInstance(); + //builder.RegisterInstance(NLogger.Instance).As().SingleInstance(); #region 注册所有IOC接口 diff --git a/Source/Core/MAX.Notification/INotification.cs b/Source/Core/MAX.Notification/INotification.cs new file mode 100644 index 0000000..490fa31 --- /dev/null +++ b/Source/Core/MAX.Notification/INotification.cs @@ -0,0 +1,7 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +namespace MAX.Notification; + +/// +/// 标记可发布的通知。 +/// +public interface INotification { } \ No newline at end of file diff --git a/Source/Core/MAX.Notification/INotificationHandler.cs b/Source/Core/MAX.Notification/INotificationHandler.cs new file mode 100644 index 0000000..8bd6726 --- /dev/null +++ b/Source/Core/MAX.Notification/INotificationHandler.cs @@ -0,0 +1,41 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +using System.Threading; +using System.Threading.Tasks; + +namespace MAX.Notification; + +/// +/// 定义通知处理器。 +/// +/// 要处理的通知类型。 +public interface INotificationHandler + where TNotification : INotification +{ + /// + /// 处理通知。 + /// + /// 要处理的通知。 + /// 取消令牌。 + Task Handle(TNotification notification, CancellationToken cancellationToken); +} + +/// +/// 同步通知处理器的基类。 +/// +/// 通知类型。 +public abstract class NotificationHandler : INotificationHandler + where TNotification : INotification +{ + Task INotificationHandler.Handle(TNotification notification, CancellationToken cancellationToken) + { + Handle(notification); + + return Task.CompletedTask; + } + + /// + /// 在派生类中重写此方法,实现同步通知处理逻辑。 + /// + /// 要处理的通知。 + protected abstract void Handle(TNotification notification); +} \ No newline at end of file diff --git a/Source/Core/MAX.Notification/INotificationPublisher.cs b/Source/Core/MAX.Notification/INotificationPublisher.cs new file mode 100644 index 0000000..6e75394 --- /dev/null +++ b/Source/Core/MAX.Notification/INotificationPublisher.cs @@ -0,0 +1,12 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Threading; + +namespace MAX.Notification; + +public interface INotificationPublisher +{ + Task Publish(IEnumerable handlerExecutors, INotification notification, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/Source/Core/MAX.Notification/IPublisher.cs b/Source/Core/MAX.Notification/IPublisher.cs new file mode 100644 index 0000000..f0159aa --- /dev/null +++ b/Source/Core/MAX.Notification/IPublisher.cs @@ -0,0 +1,28 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +using System.Threading; +using System.Threading.Tasks; + +namespace MAX.Notification; + +/// +/// 发布通知或事件,交由多个处理器处理。 +/// +public interface IPublisher +{ + /// + /// 异步向多个处理器分发通知。 + /// + /// 通知对象。 + /// 可选的取消令牌。 + /// 表示本次通知发布操作的任务。 + Task Publish(object notification, CancellationToken cancellationToken = default); + + /// + /// 异步向多个处理器分发通知。 + /// + /// 通知对象。 + /// 可选的取消令牌。 + /// 表示本次通知发布操作的任务。 + Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification; +} \ No newline at end of file diff --git a/Source/Core/MAX.Notification/LICENSE.md b/Source/Core/MAX.Notification/LICENSE.md new file mode 100644 index 0000000..8d5c9bc --- /dev/null +++ b/Source/Core/MAX.Notification/LICENSE.md @@ -0,0 +1,10 @@ +By accessing code under the [Lucky Penny Software GitHub Organization](https://github.com/LuckyPennySoftware) (Lucky Penny Software) here, you are agreeing to the following licensing terms. +If you do not agree to these terms, do not access Lucky Penny Software code. + +Your license to Lucky Penny Software source code and/or binaries is governed by the Reciprocal Public License 1.5 (RPL1.5) license as described here: + +https://opensource.org/license/rpl-1-5/ + +If you do not wish to release the source of software you build using Lucky Penny Software source code and/or binaries under the terms above, you may use Lucky Penny Software source code and/or binaries under the License Agreement described here: + +https://luckypennysoftware.com/license \ No newline at end of file diff --git a/Source/Core/MAX.Notification/MAX.Notification.csproj b/Source/Core/MAX.Notification/MAX.Notification.csproj new file mode 100644 index 0000000..f6fa592 --- /dev/null +++ b/Source/Core/MAX.Notification/MAX.Notification.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + + + + + + + diff --git a/Source/Core/MAX.Notification/NotificationHandlerExecutor.cs b/Source/Core/MAX.Notification/NotificationHandlerExecutor.cs new file mode 100644 index 0000000..9ce8f75 --- /dev/null +++ b/Source/Core/MAX.Notification/NotificationHandlerExecutor.cs @@ -0,0 +1,8 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace MAX.Notification; + +public record NotificationHandlerExecutor(object HandlerInstance, Func HandlerCallback); \ No newline at end of file diff --git a/Source/Core/MAX.Notification/NotificationPublisher.cs b/Source/Core/MAX.Notification/NotificationPublisher.cs new file mode 100644 index 0000000..4412a69 --- /dev/null +++ b/Source/Core/MAX.Notification/NotificationPublisher.cs @@ -0,0 +1,67 @@ + // 摘自 MediatR 14.2.0 的 Mediator.cs,仅保留通知功能。 + // 许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 + using System.Collections.Concurrent; + using MAX.Notification.NotificationPublishers; + using MAX.Notification.Wrappers; + + namespace MAX.Notification; + + public class NotificationPublisher : IPublisher + { + private readonly IServiceProvider _serviceProvider; + private readonly INotificationPublisher _publisher; + private static readonly ConcurrentDictionary _notificationHandlers = new(); + + public NotificationPublisher(IServiceProvider serviceProvider) + : this(serviceProvider, new ForeachAwaitPublisher()) { } + + public NotificationPublisher(IServiceProvider serviceProvider, INotificationPublisher publisher) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(publisher); + _serviceProvider = serviceProvider; + _publisher = publisher; + } + public Task Publish(TNotification notification, CancellationToken cancellationToken = default) + where TNotification : INotification + { + if (notification == null) + { + throw new ArgumentNullException(nameof(notification)); + } + + return PublishNotification(notification, cancellationToken); + } + + public Task Publish(object notification, CancellationToken cancellationToken = default) => + notification switch + { + null => throw new ArgumentNullException(nameof(notification)), + INotification instance => PublishNotification(instance, cancellationToken), + _ => throw new ArgumentException($"{nameof(notification)} does not implement ${nameof(INotification)}") + }; + + /// + /// 在派生类中重写此方法可控制任务的等待方式。默认调用 。 + /// + /// 封装各通知处理器实例及其调用委托的执行器集合。 + /// 要发布的通知。 + /// 取消令牌。 + /// 表示本次处理器分发操作的任务。 + protected virtual Task PublishCore(IEnumerable handlerExecutors, INotification notification, CancellationToken cancellationToken) + => _publisher.Publish(handlerExecutors, notification, cancellationToken); + + private Task PublishNotification(INotification notification, CancellationToken cancellationToken = default) + { + var handler = _notificationHandlers.GetOrAdd(notification.GetType(), static notificationType => + { + var wrapperType = typeof(NotificationHandlerWrapperImpl<>).MakeGenericType(notificationType); + var wrapper = Activator.CreateInstance(wrapperType) ?? throw new InvalidOperationException($"Could not create wrapper for type {notificationType}"); + return (NotificationHandlerWrapper)wrapper; + }); + + return handler.Handle(notification, _serviceProvider, PublishCore, cancellationToken); + } + + +} diff --git a/Source/Core/MAX.Notification/NotificationPublishers/ForeachAwaitPublisher.cs b/Source/Core/MAX.Notification/NotificationPublishers/ForeachAwaitPublisher.cs new file mode 100644 index 0000000..3250793 --- /dev/null +++ b/Source/Core/MAX.Notification/NotificationPublishers/ForeachAwaitPublisher.cs @@ -0,0 +1,25 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MAX.Notification.NotificationPublishers; + +/// +/// 在循环中依次调用并等待各通知处理器: +/// +/// foreach (var handler in handlers) { +/// await handler(notification, cancellationToken); +/// } +/// +/// +public class ForeachAwaitPublisher : INotificationPublisher +{ + public async Task Publish(IEnumerable handlerExecutors, INotification notification, CancellationToken cancellationToken) + { + foreach (var handler in handlerExecutors) + { + await handler.HandlerCallback(notification, cancellationToken).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/Source/Core/MAX.Notification/NotificationPublishers/TaskWhenAllPublisher.cs b/Source/Core/MAX.Notification/NotificationPublishers/TaskWhenAllPublisher.cs new file mode 100644 index 0000000..acbd870 --- /dev/null +++ b/Source/Core/MAX.Notification/NotificationPublishers/TaskWhenAllPublisher.cs @@ -0,0 +1,29 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace MAX.Notification.NotificationPublishers; + +/// +/// 使用 Task.WhenAll 等待通知处理器任务集合: +/// +/// var tasks = handlers +/// .Select(handler => handler.Handle(notification, cancellationToken)) +/// .ToList(); +/// +/// return Task.WhenAll(tasks); +/// +/// +public class TaskWhenAllPublisher : INotificationPublisher +{ + public Task Publish(IEnumerable handlerExecutors, INotification notification, CancellationToken cancellationToken) + { + var tasks = handlerExecutors + .Select(handler => handler.HandlerCallback(notification, cancellationToken)) + .ToArray(); + + return Task.WhenAll(tasks); + } +} \ No newline at end of file diff --git a/Source/Core/MAX.Notification/README.md b/Source/Core/MAX.Notification/README.md new file mode 100644 index 0000000..5f5aa83 --- /dev/null +++ b/Source/Core/MAX.Notification/README.md @@ -0,0 +1,67 @@ +# MAX.Notification + +从本地 MediatR 14.2.0 源码抽取的通知组件,仅依赖 .NET 8,不引用任何第三方 NuGet 包。原许可声明完整保存在 LICENSE.md;源码来源和修改记录见 UPSTREAM.md。不包含请求响应、管道、流、许可证检测或容器扫描功能。移除第三方依赖不改变原源码的许可条件。 + +## 定义与使用 + +```csharp +using MAX.Notification; + +public sealed record ProductChanged(int ProductId) : INotification; + +public sealed class RefreshCacheHandler : INotificationHandler +{ + public Task Handle(ProductChanged notification, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + // 更新缓存,或返回真正的异步业务操作。 + return Task.CompletedTask; + } +} +``` + +发布器通过标准 `IServiceProvider.GetService(typeof(IEnumerable>))` 获取处理器。调用方可自行实现此接口;组件不拥有、不释放容器及处理器。无容器实现可参考测试项目中的 TestServices。 + +宿主已经使用 Microsoft DI 时,可以在宿主中注册(以下扩展方法来自宿主自己的 DI 包,本项目无须引用): + +```csharp +services.AddTransient, RefreshCacheHandler>(); +services.AddScoped(sp => new NotificationPublisher(sp)); +// 在有效作用域内获取 IPublisher 后: +await publisher.Publish(new ProductChanged(42), cancellationToken); +``` + +宿主使用 Autofac 时: + +```csharp +builder.RegisterType() + .As>(); +builder.Register(ctx => new NotificationPublisher( + new AutofacServiceProvider(ctx.Resolve()))) + .As().InstancePerLifetimeScope(); +``` + +上述 Autofac 示例需要宿主已有 Autofac.Extensions.DependencyInjection,或者宿主自己实现 IServiceProvider 适配器。不要把捕获短期作用域的发布器注册为单例。 + +## 行为与边界 + +- 默认 ForeachAwaitPublisher:按容器返回顺序逐个等待;Handler 抛异常或返回取消任务后停止,已完成的操作不会回滚。可实现 INotificationPublisher 自定义分发策略。 +- 并发策略:构造时传入 new MAX.Notification.NotificationPublishers.TaskWhenAllPublisher()。使用原版 Task.WhenAll 实现;Handler 同步抛异常会中断后续枚举,已启动任务不会由此次调用统一等待。返回失败 Task 则会继续启动其他 Handler 并等待全部完成。没有 Task.Run,不保证运行在后台线程,不应并发共享非线程安全资源。 +- 按通知实际运行时类型解析;不自动查找基类通知处理器,是否支持逆变解析取决于容器。 +- 一次发布内相同处理器实际类型只执行第一个实例;不同类型各执行一次。这也会合并同类型但不同配置的实例。 +- 无处理器时成功返回。每次发布重新解析,只缓存类型适配器,不缓存处理器或页面实例。 +- 取消行为与原版一致:只传递原始 CancellationToken,发布器不主动检查取消。Handler 应自行检查或把令牌传入异步操作;没有 Handler 时,已取消的令牌不会使发布失败。 +- 无消息去重、持久化、重试、事务、跨进程通信或 UI 线程调度。重复 Publish 仍会重复执行。 +- 页面关闭不会自动影响 DI 中的处理器;业务 Handler 应与页面生命周期解耦。 +- 使用运行时泛型反射构建适配器;当前未提供 NativeAOT / trimming 支持保证。 + +## 验证 + +```powershell +dotnet build Source/Core/MAX.Notification/MAX.Notification.csproj -c Release +dotnet run --project Tests/MAX.Notification.SmokeTests/MAX.Notification.SmokeTests.csproj -c Release +``` + +测试项目同样不依赖第三方包,覆盖类型分发、重复处理器、重新解析、空处理器、输入校验、异常、顺序/并发及取消。 + +支持继承 NotificationHandler 实现同步处理器,覆写 protected Handle(notification);同步基类本身不检查取消。 diff --git a/Source/Core/MAX.Notification/UPSTREAM.md b/Source/Core/MAX.Notification/UPSTREAM.md new file mode 100644 index 0000000..5763599 --- /dev/null +++ b/Source/Core/MAX.Notification/UPSTREAM.md @@ -0,0 +1,25 @@ +# 源码来源与修改记录 + +来源:用户提供的 `D:\Sources\MediatR-14.2.0`。 +上游项目:https://github.com/LuckyPennySoftware/MediatR + +LICENSE.md 为原目录同名文件的原样副本,声明 RPL 1.5 或商业许可。移除依赖不改变原许可要求。 + +## 直接抽取 + +- src/MediatR.Contracts/INotification.cs +- src/MediatR/INotificationHandler.cs(含同步 NotificationHandler 基类) +- src/MediatR/IPublisher.cs +- src/MediatR/INotificationPublisher.cs +- src/MediatR/NotificationHandlerExecutor.cs +- src/MediatR/NotificationPublishers/ForeachAwaitPublisher.cs +- src/MediatR/NotificationPublishers/TaskWhenAllPublisher.cs +- src/MediatR/Wrappers/NotificationHandlerWrapper.cs + +以上代码注释已翻译为中文,许可声明保留原文;命名空间由 MediatR 改为 MAX.Notification,并添加来源说明。Wrapper 移除 Microsoft.Extensions.DependencyInjection,GetServices() 改为标准 IServiceProvider.GetService(typeof(IEnumerable)),空结果使用空集合,错误返回类型抛出转换异常。保留原处理器类型去重逻辑。 + +NotificationPublisher.cs 从 src/MediatR/Mediator.cs 抽取两个 Publish 重载、PublishCore、PublishNotification、通知缓存及相关字段。入口类改名,保留默认和自定义发布策略构造方式,增加构造参数 null 校验。去除请求响应、流以及第三方许可证检测调用,保留许可声明。 + +项目配置、使用说明和测试为新增内容。库不依赖第三方 NuGet 包,也排除全局 PolySharp 引用。 + +保留原版行为:只传递 CancellationToken,不主动检查取消;TaskWhenAllPublisher 枚举期间遇到同步异常时停止启动后续处理器。 diff --git a/Source/Core/MAX.Notification/Wrappers/NotificationHandlerWrapper.cs b/Source/Core/MAX.Notification/Wrappers/NotificationHandlerWrapper.cs new file mode 100644 index 0000000..a1e5e2d --- /dev/null +++ b/Source/Core/MAX.Notification/Wrappers/NotificationHandlerWrapper.cs @@ -0,0 +1,32 @@ +// 摘自 MediatR 14.2.0,许可声明与修改记录见 LICENSE.md 和 UPSTREAM.md。 +namespace MAX.Notification.Wrappers; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + + +public abstract class NotificationHandlerWrapper +{ + public abstract Task Handle(INotification notification, IServiceProvider serviceFactory, + Func, INotification, CancellationToken, Task> publish, + CancellationToken cancellationToken); +} + +public class NotificationHandlerWrapperImpl : NotificationHandlerWrapper + where TNotification : INotification +{ + public override Task Handle(INotification notification, IServiceProvider serviceFactory, + Func, INotification, CancellationToken, Task> publish, + CancellationToken cancellationToken) + { + var handlers = ((IEnumerable>?)serviceFactory.GetService(typeof(IEnumerable>)) ?? Array.Empty>()) + .GroupBy(static x => x.GetType()) + .Select(static g => g.First()) + .Select(static x => new NotificationHandlerExecutor(x, (theNotification, theToken) => x.Handle((TNotification)theNotification, theToken))); + + return publish(handlers, notification, cancellationToken); + } +} \ No newline at end of file diff --git a/Source/Store.Management.Api/ApiClientOptions.cs b/Source/Store.Management.Api/ApiClientOptions.cs new file mode 100644 index 0000000..35e35be --- /dev/null +++ b/Source/Store.Management.Api/ApiClientOptions.cs @@ -0,0 +1,19 @@ +namespace Store.Management.Api; + +public sealed class ApiClientOptions +{ + public required Uri BaseUrl { get; init; } + public string Version { get; init; } = "v1"; + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30); + + internal void Validate() + { + if (BaseUrl is null || !BaseUrl.IsAbsoluteUri || + (BaseUrl.Scheme != "https" && BaseUrl.Scheme != "http") || + !string.IsNullOrEmpty(BaseUrl.UserInfo) || !string.IsNullOrEmpty(BaseUrl.Query) || !string.IsNullOrEmpty(BaseUrl.Fragment)) + throw new ArgumentException("BaseUrl 必须是无凭据、查询参数和片段的 HTTP(S) 绝对地址。"); + if (!System.Text.RegularExpressions.Regex.IsMatch(Version, @"^v\d+(\.\d+)?$")) + throw new ArgumentException("Version 格式应为 v1 或 v1.0。"); + if (Timeout <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(Timeout)); + } +} diff --git a/Source/Store.Management.Api/Authentication/AuthApi.cs b/Source/Store.Management.Api/Authentication/AuthApi.cs new file mode 100644 index 0000000..85a2772 --- /dev/null +++ b/Source/Store.Management.Api/Authentication/AuthApi.cs @@ -0,0 +1,67 @@ +using System.ComponentModel.DataAnnotations; +using RestSharp; +using Store.Management.Api.Transport; + +namespace Store.Management.Api.Authentication; + +public sealed class LoginRequest +{ + [Required] public string UserName { get; set; } = ""; + [Required] public string Password { get; set; } = ""; + [StringLength(100)] public string? DeviceId { get; set; } + [StringLength(100)] public string? DeviceName { get; set; } +} + +public sealed class LoginResult +{ + public string TokenType { get; init; } = "Bearer"; + public string Token { get; init; } = ""; + public uint ExpiresInMinutes { get; init; } +} + +public sealed record CurrentUser(string UserName, string? NickName); + +public interface IAuthApi +{ + Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default); + Task LogoutAsync(CancellationToken cancellationToken = default); + Task MeAsync(CancellationToken cancellationToken = default); +} + +internal sealed class AuthApi(ApiTransport transport, TokenSession session) : IAuthApi +{ + // 登录和退出串行执行,避免迟到的登录响应覆盖退出操作。 + private readonly SemaphoreSlim _gate = new(1, 1); + + public async Task LoginAsync(LoginRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + var result = await transport.GetRequiredAsync("Auth/Login", Method.Post, request, + authenticated: false, cancellationToken: cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(result.Token) || result.Token.Contains('\r') || result.Token.Contains('\n') || + !string.Equals(result.TokenType, "Bearer", StringComparison.OrdinalIgnoreCase)) + throw new ApiException(ApiErrorKind.InvalidResponse, "登录响应缺少有效的 Bearer Token。"); + session.Set(result.Token); + return result; + } + finally { _gate.Release(); } + } + + public async Task LogoutAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + var token = session.Token; + try + { + if (token is not null) + await transport.SendAsync("Auth/Logout", Method.Post, cancellationToken: cancellationToken).ConfigureAwait(false); + } + finally { session.ClearIfCurrent(token); _gate.Release(); } + } + + public Task MeAsync(CancellationToken cancellationToken = default) + => transport.GetRequiredAsync("Auth/Me", Method.Get, cancellationToken: cancellationToken); +} diff --git a/Source/Store.Management.Api/Authentication/TokenSession.cs b/Source/Store.Management.Api/Authentication/TokenSession.cs new file mode 100644 index 0000000..dcc5815 --- /dev/null +++ b/Source/Store.Management.Api/Authentication/TokenSession.cs @@ -0,0 +1,16 @@ +namespace Store.Management.Api.Authentication; + +/// 会话仅保存在内存中,不将密码或 Token 写入配置与日志。 +public sealed class TokenSession +{ + private string? _token; + public bool IsAuthenticated => Volatile.Read(ref _token) is not null; + internal string? Token => Volatile.Read(ref _token); + internal void Set(string token) => Interlocked.Exchange(ref _token, token); + public void Clear() => Interlocked.Exchange(ref _token, null); + // 旧请求的 401 不应清除重新登录后建立的新会话。 + internal void ClearIfCurrent(string? token) + { + if (token is not null) Interlocked.CompareExchange(ref _token, null, token); + } +} diff --git a/Source/Store.Management.Api/Models/ApiResult.cs b/Source/Store.Management.Api/Models/ApiResult.cs new file mode 100644 index 0000000..55f6982 --- /dev/null +++ b/Source/Store.Management.Api/Models/ApiResult.cs @@ -0,0 +1,19 @@ +namespace Store.Management.Api.Models; + +public sealed class ApiResult +{ + public required int Code { get; init; } + public string? Msg { get; init; } + public string? TraceId { get; init; } + public T? Data { get; init; } +} + +public sealed record PageResult(long Total, IReadOnlyList Items); + +public class PageRequest +{ + [System.ComponentModel.DataAnnotations.Range(1, 1000000)] + public int Page { get; set; } = 1; + [System.ComponentModel.DataAnnotations.Range(1, 200)] + public int PageSize { get; set; } = 50; +} diff --git a/Source/Store.Management.Api/README.md b/Source/Store.Management.Api/README.md new file mode 100644 index 0000000..a19be61 --- /dev/null +++ b/Source/Store.Management.Api/README.md @@ -0,0 +1,78 @@ +# Store Management API 客户端 + +独立的 net10.0 类库,使用 RestSharp 112.1.0 连接 StoreWeb。WPF 项目已引用该类库。每个服务地址及用户会话创建一个 StoreApiClient,建议在应用容器注册为单例,退出应用时释放。 + +## 调用 + +```csharp +using Store.Management.Api; +using Store.Management.Api.Authentication; +using Store.Management.Api.Users; +using Store.Management.Api.Transport; + +using var api = new StoreApiClient(new ApiClientOptions +{ + BaseUrl = new Uri("https://your-server/"), // 替换为实际服务地址 + Version = "v1", + Timeout = TimeSpan.FromSeconds(30) +}); +try +{ + await api.Auth.LoginAsync(new LoginRequest + { + UserName = userName, + Password = password, + DeviceId = deviceId, // 可选;稳定设备标识由宿主应用管理 + DeviceName = Environment.MachineName + }); + var me = await api.Auth.MeAsync(); + var page = await api.Users.SearchAsync(new UserSearchRequest + { + Page = 1, PageSize = 50, Keyword = "" + }); + var created = await api.Users.CreateAsync(new UserCreateRequest + { + UserName = "new-user", Password = initialPassword, NickName = "新用户" + }); + await api.Users.UpdateAsync(new UserUpdateRequest { Id = created.Id, NickName = "修改后的昵称" }); + await api.Auth.LogoutAsync(); +} +catch (ApiException ex) +{ + // 显示 ex.Message;根据 Kind 区分失败类型,TraceId 可用于联系服务端排查。 + // Unauthorized 时导航登录页;HTTP 403 不清除会话。 +} +``` + +示例中的账号、密码、设备标识来自调用方输入。异步方法均支持 CancellationToken;主动取消抛出 OperationCanceledException,本地输入校验抛出 ValidationException。 + +## 文件与职责 + +- StoreApiClient.cs:组合会话、传输层与业务模块,集中管理生命周期。 +- ApiClientOptions.cs:服务地址、版本和超时配置。 +- Transport/ApiTransport.cs:唯一 HTTP 出口,统一 Bearer、JSON、查询参数、校验及错误映射。 +- Transport/ApiException.cs:区分网络、超时、HTTP、业务、响应格式与认证错误,保留状态码、业务码与 TraceId。 +- Authentication/:登录、退出、当前用户;Token 仅存内存。 +- Models/:服务端响应信封与分页模型。 +- Users/:用户分页、查询、添加与修改,DTO 只包含当前基础功能所需字段。 + +## 协议与扩展 + +对齐 StoreWeb 的 AuthController、TokenResult、UnifyResultFilter 和用户管理控制器。成功码为 200;HTTP 200 内的业务失败同样抛出 ApiException。登录读取 data.token,认证采用 Bearer opaque Token,不解析 JWT,不根据首次登录的有效期自行判定续期。HTTP 或业务 401 清除当前请求所用的会话;旧请求的 401 不清除新 Token。 + +后续商品模块按 Users 的方式新增独立 DTO、接口和实现,由 StoreApiClient 暴露模块入口。模块通过 Transport.GetRequiredAsync 发送有数据请求,通过 SendAsync 发送无数据操作。路由采用 user/GetAllUser 这样的相对路径,传输层自动添加版本。简单参数使用 query,复杂参数使用 body,HTTP 方法与服务端契约一致。避免按常规 REST 习惯猜测当前服务端的动作路由。 + +请求不自动重试,避免添加、修改操作重复执行。禁止自动重定向;服务地址应直接指向最终 API 地址。退出时即使服务端请求失败也清理本地 Token,并将错误交给宿主;此时服务端会话可能仍有效。Session.Clear 可用于本地退出,不撤销服务器会话。 + +当前不包含登录界面、Token 磁盘持久化、自动刷新或商品接口。实际地址由宿主提供,尚未进行真实服务联调。 + +## 验证 + +在仓库根目录运行: + +```powershell +dotnet build Store.Management.slnx +dotnet run --project Tests/Store.Management.Api.SmokeTests/Store.Management.Api.SmokeTests.csproj +``` + +模拟 HTTP 测试覆盖登录路由、Bearer、分页序列化与解析、输入边界、业务错误、无效 JSON、403、HTTP 与业务 401、退出及主动取消,不会调用真实服务。 diff --git a/Source/Store.Management.Api/Store.Management.Api.csproj b/Source/Store.Management.Api/Store.Management.Api.csproj new file mode 100644 index 0000000..c393e7a --- /dev/null +++ b/Source/Store.Management.Api/Store.Management.Api.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Source/Store.Management.Api/StoreApiClient.cs b/Source/Store.Management.Api/StoreApiClient.cs new file mode 100644 index 0000000..ff12723 --- /dev/null +++ b/Source/Store.Management.Api/StoreApiClient.cs @@ -0,0 +1,29 @@ +using Store.Management.Api.Authentication; +using Store.Management.Api.Transport; +using Store.Management.Api.Users; + +namespace Store.Management.Api; + +/// 一个服务地址与一个用户会话对应一个实例;在应用容器中注册为单例。 +public sealed class StoreApiClient : IDisposable +{ + public TokenSession Session { get; } = new(); + public ApiTransport Transport { get; } + public IAuthApi Auth { get; } + public IUserApi Users { get; } + + /// 可选的外部 HTTP 客户端,由调用方管理其生命周期。 + public StoreApiClient(ApiClientOptions options, HttpClient? httpClient = null) + { + ArgumentNullException.ThrowIfNull(options); + Transport = new ApiTransport(options, Session, httpClient); + Auth = new AuthApi(Transport, Session); + Users = new UserApi(Transport); + } + + public void Dispose() + { + Session.Clear(); + Transport.Dispose(); + } +} diff --git a/Source/Store.Management.Api/Transport/ApiException.cs b/Source/Store.Management.Api/Transport/ApiException.cs new file mode 100644 index 0000000..3b38dc7 --- /dev/null +++ b/Source/Store.Management.Api/Transport/ApiException.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace Store.Management.Api.Transport; + +public enum ApiErrorKind { Network, Timeout, Http, Business, InvalidResponse, Unauthorized } + +public sealed class ApiException : Exception +{ + public ApiErrorKind Kind { get; } + public HttpStatusCode? StatusCode { get; } + public int? Code { get; } + public string? TraceId { get; } + + public ApiException(ApiErrorKind kind, string message, HttpStatusCode? statusCode = null, + int? code = null, string? traceId = null, Exception? innerException = null) + : base(message, innerException) + => (Kind, StatusCode, Code, TraceId) = (kind, statusCode, code, traceId); +} diff --git a/Source/Store.Management.Api/Transport/ApiTransport.cs b/Source/Store.Management.Api/Transport/ApiTransport.cs new file mode 100644 index 0000000..a7a8dad --- /dev/null +++ b/Source/Store.Management.Api/Transport/ApiTransport.cs @@ -0,0 +1,82 @@ +using System.ComponentModel.DataAnnotations; +using System.Net; +using System.Text.Json; +using RestSharp; +using Store.Management.Api.Authentication; +using Store.Management.Api.Models; + +namespace Store.Management.Api.Transport; + +/// 唯一 HTTP 出口;模块仅指定相对路由、请求数据与响应类型。 +public sealed class ApiTransport : IDisposable +{ + private readonly RestClient _client; + private readonly TokenSession _session; + private readonly string _version; + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + internal ApiTransport(ApiClientOptions options, TokenSession session, HttpClient? httpClient) + { + options.Validate(); + _session = session; + _version = options.Version; + var settings = new RestClientOptions(new Uri(options.BaseUrl.AbsoluteUri.TrimEnd('/') + "/")) + { + Timeout = options.Timeout, + FollowRedirects = false + }; + _client = httpClient is null ? new RestClient(settings) : new RestClient(httpClient, settings, disposeHttpClient: false); + } + + public async Task SendAsync(string route, Method method, object? body = null, + IReadOnlyDictionary? query = null, bool authenticated = true, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(route) || route.StartsWith('/') || route.Contains("..") || + route.Contains(':') || route.Contains('?') || route.Contains('#') || route.Contains('\\')) + throw new ArgumentException("接口必须使用相对路由,例如 user/GetAllUser。", nameof(route)); + if (body is not null) Validator.ValidateObject(body, new ValidationContext(body), true); + cancellationToken.ThrowIfCancellationRequested(); + var token = authenticated ? _session.Token : null; + if (authenticated && token is null) throw new ApiException(ApiErrorKind.Unauthorized, "请先登录。", code: 401); + var request = new RestRequest($"{_version}/{route}", method); + if (token is not null) request.AddHeader("Authorization", $"Bearer {token}"); + if (body is not null) request.AddJsonBody(body); + if (query is not null) + foreach (var pair in query) + if (pair.Value is not null) request.AddQueryParameter(pair.Key, pair.Value); + var response = await _client.ExecuteAsync(request, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (response.ResponseStatus == ResponseStatus.TimedOut) + throw new ApiException(ApiErrorKind.Timeout, "请求超时,请稍后重试。", innerException: response.ErrorException); + if ((int)response.StatusCode == 0) + throw new ApiException(ApiErrorKind.Network, "无法连接服务器,请检查网络与服务地址。", innerException: response.ErrorException); + + ApiResult? result = null; + JsonException? parseError = null; + try { result = JsonSerializer.Deserialize>(response.Content ?? "", Json); } + catch (JsonException ex) { parseError = ex; } + if (response.StatusCode == HttpStatusCode.Unauthorized || result?.Code == 401) + { + _session.ClearIfCurrent(token); + throw new ApiException(ApiErrorKind.Unauthorized, result?.Msg ?? "登录已失效,请重新登录。", + response.StatusCode, 401, result?.TraceId); + } + if (!response.IsSuccessful) + throw new ApiException(ApiErrorKind.Http, result?.Msg ?? $"服务器请求失败({(int)response.StatusCode})。", + response.StatusCode, result?.Code, result?.TraceId); + if (result is null) + throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应格式无效。", response.StatusCode, innerException: parseError); + if (result.Code != 200) + throw new ApiException(ApiErrorKind.Business, result.Msg ?? "操作失败。", response.StatusCode, result.Code, result.TraceId); + return result.Data; + } + + public async Task GetRequiredAsync(string route, Method method, object? body = null, + IReadOnlyDictionary? query = null, bool authenticated = true, CancellationToken cancellationToken = default) + where T : class + => await SendAsync(route, method, body, query, authenticated, cancellationToken).ConfigureAwait(false) + ?? throw new ApiException(ApiErrorKind.InvalidResponse, "服务器响应缺少 data。"); + + public void Dispose() => _client.Dispose(); +} diff --git a/Source/Store.Management.Api/Users/UserApi.cs b/Source/Store.Management.Api/Users/UserApi.cs new file mode 100644 index 0000000..e528c86 --- /dev/null +++ b/Source/Store.Management.Api/Users/UserApi.cs @@ -0,0 +1,38 @@ +using RestSharp; +using Store.Management.Api.Models; +using Store.Management.Api.Transport; + +namespace Store.Management.Api.Users; + +public interface IUserApi +{ + Task> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default); + Task GetAsync(string? userName = null, CancellationToken cancellationToken = default); + Task CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default); + Task UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default); +} + +internal sealed class UserApi(ApiTransport transport) : IUserApi +{ + public Task> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return transport.GetRequiredAsync>("user/GetAllUser", Method.Post, request, cancellationToken: cancellationToken); + } + + public Task GetAsync(string? userName = null, CancellationToken cancellationToken = default) + => transport.GetRequiredAsync("user/GetUser", Method.Post, + query: new Dictionary { ["userName"] = userName }, cancellationToken: cancellationToken); + + public Task CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return transport.GetRequiredAsync("user/AddUser", Method.Post, request, cancellationToken: cancellationToken); + } + + public Task UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return transport.GetRequiredAsync("user/UpdateUser", Method.Post, request, cancellationToken: cancellationToken); + } +} diff --git a/Source/Store.Management.Api/Users/UserModels.cs b/Source/Store.Management.Api/Users/UserModels.cs new file mode 100644 index 0000000..d23bca7 --- /dev/null +++ b/Source/Store.Management.Api/Users/UserModels.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using Store.Management.Api.Models; + +namespace Store.Management.Api.Users; + +public sealed class UserSearchRequest : PageRequest +{ + [StringLength(100)] public string? Keyword { get; set; } + public int? GroupId { get; set; } + [StringLength(20)] public string? ParentUserName { get; set; } +} + +public class UserEditRequest +{ + [StringLength(50)] public string? NickName { get; set; } + [StringLength(30)] public string? Mobile { get; set; } + [StringLength(100)] public string? CompanyName { get; set; } + [StringLength(255)] public string? Notes { get; set; } +} + +public sealed class UserCreateRequest : UserEditRequest +{ + [Required, StringLength(20)] public string UserName { get; set; } = ""; + [Required, StringLength(128, MinimumLength = 6)] public string Password { get; set; } = ""; + [StringLength(20)] public string? ParentUserName { get; set; } +} + +public sealed class UserUpdateRequest : UserEditRequest +{ + [Range(1, int.MaxValue)] public int Id { get; set; } +} + +/// 管理端当前使用的用户资料字段;不接收密码。 +public sealed class UserInfo +{ + public int Id { get; init; } + public string UserName { get; init; } = ""; + public string? NickName { get; init; } + public string? Mobile { get; init; } + public string? CompanyName { get; init; } + public string? Notes { get; init; } + public string? ParentUserName { get; init; } + public int GroupId { get; init; } + public decimal Points { get; init; } + public decimal? Balance { get; init; } +} diff --git a/Source/Store.Management.Framework/Config.cs b/Source/Store.Management.Framework/Config.cs new file mode 100644 index 0000000..acc640d --- /dev/null +++ b/Source/Store.Management.Framework/Config.cs @@ -0,0 +1,61 @@ +using Store.Management.Framework.Configs; +using System.Diagnostics; +using System.Reflection; +using System.Security; +using System.Security.Principal; + +namespace Store.Management.Framework; + +/// 当前进程的应用信息;目录与持久化设置分别由 FolderConfig 和设置服务管理。 +public static class Config +{ + public static Guid SessionID { get; } = Guid.NewGuid(); + + /// 进程启动时间(本地时间),不受首次访问 Config 的时机影响。 + public static DateTime AppStartTime { get; } = GetAppStartTime(); + + public static string AppName => FolderConfig.AppName; + public static string AppFullName => AppName; + public static Version WindowsVersion => Environment.OSVersion.Version; + + /// 优先使用入口程序集文件版本;单文件发布等场景回退到程序集版本。 + public static Version Version { get; } = GetVersion(); + + /// 入口程序集的编译配置,不使用是否附加调试器作为判断依据。 + public static bool IsRelease { get; } = !string.Equals( + EntryAssembly.GetCustomAttribute()?.Configuration, + "Debug", StringComparison.OrdinalIgnoreCase); + + public static bool IsAdministrator + { + get + { + if (!OperatingSystem.IsWindows()) return false; + try + { + using WindowsIdentity identity = WindowsIdentity.GetCurrent(); + return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); + } + catch (SecurityException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } + } + + private static Assembly EntryAssembly => Assembly.GetEntryAssembly() ?? typeof(Config).Assembly; + + public static string GetFullVersionString() => + $"{Version} - {(IsRelease ? "Release" : "Debug")}{(IsAdministrator ? " - 管理员" : string.Empty)}"; + + private static Version GetVersion() + { + string? fileVersion = EntryAssembly.GetCustomAttribute()?.Version; + return System.Version.TryParse(fileVersion, out Version? version) + ? version : EntryAssembly.GetName().Version ?? new Version(0, 0, 0, 0); + } + + private static DateTime GetAppStartTime() + { + using Process process = Process.GetCurrentProcess(); + return process.StartTime; + } +} diff --git a/Source/Store.Management.Framework/Configs/FolderConfig.cs b/Source/Store.Management.Framework/Configs/FolderConfig.cs new file mode 100644 index 0000000..d8197d7 --- /dev/null +++ b/Source/Store.Management.Framework/Configs/FolderConfig.cs @@ -0,0 +1,51 @@ +using MAX.Framework.Interfaces; +using System.IO; + +namespace Store.Management.Framework.Configs; + +/// 应用目录约定。路径读取不创建目录,保存配置时自动创建所需目录。 +public sealed class FolderConfig : IFolderConfig +{ + /// 供启动配置使用;容器也可通过 IFolderConfig 解析目录配置。 + public static FolderConfig Instance { get; } = new(); + + public FolderConfig() { } + + string IFolderConfig.ConfigFolder => ConfigFolder; + string IFolderConfig.DataFolder => DataFolder; + string IFolderConfig.LoggingFolder => LoggingFolder; + string IFolderConfig.CacheFolder => CacheFolder; + + public const string AppName = "Store.Management"; + + /// 安装目录,用于读取随程序分发的资源。 + public static string ExeFolder => AppContext.BaseDirectory; + + /// 当前用户的数据根目录:%LOCALAPPDATA%\Store.Management。 + public static string AppFolder => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName); + + public static string ConfigFolder => Path.Combine(AppFolder, "Config"); + public static string DataFolder => Path.Combine(AppFolder, "Data"); + public static string LoggingFolder => Path.Combine(AppFolder, "Logs"); + public static string DatabaseFolder => Path.Combine(AppFolder, "Db"); + public static string CacheFolder => Path.Combine(AppFolder, "Cache"); + public static string AppSettingsFilePath => GetSettingsFilePath("AppSettings.json"); + + /// 获取配置文件路径,仅接受文件名,防止写出配置目录。 + public static string GetSettingsFilePath(string fileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + if (fileName is "." or ".." || fileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || fileName.EndsWith('.') || fileName.EndsWith(' ')) + throw new ArgumentException("配置文件名不能包含路径、非法字符或以点、空格结尾。", nameof(fileName)); + return Path.Combine(ConfigFolder, fileName); + } + + /// 按需在宿主启动时创建应用目录;创建失败向调用者报告。 + public static void Initialize() + { + foreach (string path in new[] { ConfigFolder, DataFolder, LoggingFolder, DatabaseFolder, CacheFolder }) + Directory.CreateDirectory(path); + } +} diff --git a/Source/Store.Management.Framework/Controls/DialogWindow.xaml.cs b/Source/Store.Management.Framework/Controls/DialogWindow.xaml.cs index 5975094..4f7bc09 100644 --- a/Source/Store.Management.Framework/Controls/DialogWindow.xaml.cs +++ b/Source/Store.Management.Framework/Controls/DialogWindow.xaml.cs @@ -78,6 +78,12 @@ public partial class DialogWindow : WindowBase, IDialogWindow protected override void OnContentChanged(object oldContent, object newContent) { + // InitializeComponent 设置初始内容时,窗口样式尚未加载。 + if (!isConstructorSuccessful) + { + base.OnContentChanged(oldContent, newContent); + return; + } this.Style = dialogWindowStyle; if (newContent is UserControl control) { @@ -124,9 +130,9 @@ public partial class DialogWindow : WindowBase, IDialogWindow protected override void OnStyleChanged(Style oldStyle, Style newStyle) { - if (newStyle.TargetType == typeof(Window)) + if (newStyle?.TargetType == typeof(Window)) { - if (isConstructorSuccessful) + if (isConstructorSuccessful && !ReferenceEquals(dialogWindowStyle, newStyle)) { dialogWindowStyle.Merge(newStyle); } diff --git a/Source/Store.Management.Framework/Converters/StringNullOrEmpty2VisibilityConverter.cs b/Source/Store.Management.Framework/Converters/StringNullOrEmpty2VisibilityConverter.cs new file mode 100644 index 0000000..503ba3d --- /dev/null +++ b/Source/Store.Management.Framework/Converters/StringNullOrEmpty2VisibilityConverter.cs @@ -0,0 +1,15 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Store.Management.Framework.Converters; + +/// 空字符串或 null 显示占位内容,其他值折叠;仅用于单向绑定。 +public sealed class StringNullOrEmpty2VisibilityConverter : IValueConverter +{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is null || value is string { Length: 0 } ? Visibility.Visible : Visibility.Collapsed; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + => Binding.DoNothing; +} diff --git a/Source/Store.Management.Framework/FrameworkApplication.cs b/Source/Store.Management.Framework/FrameworkApplication.cs index 61e89ba..a794f3d 100644 --- a/Source/Store.Management.Framework/FrameworkApplication.cs +++ b/Source/Store.Management.Framework/FrameworkApplication.cs @@ -1,51 +1,150 @@ -using Prism.Ioc; -using Prism.Mvvm; -using Prism.Services.Dialogs; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Windows; -using Store.Management.Framework.Extensions; +using MAX.Framework.Logger; +using NLog; using Prism.Autofac; +using Prism.Ioc; +using Prism.Services.Dialogs; +using Store.Management.Framework.Configs; +using Store.Management.Framework.Extensions; +using Store.Management.Framework.Services; +using System.Threading; +using System.Windows; +using System.Windows.Threading; namespace Store.Management.Framework; public abstract class FrameworkApplication : PrismApplication { + private Mutex? _mutex; + private bool _ownsMutex; + private int _fatalHandling; + private int _shutdownRequested; + public static bool ShuttingDown { get; private set; } + public static bool IsAppInitialized { get; private set; } + public static event Action? FatalErrorRaised; - protected FrameworkApplication() - { + protected FrameworkApplication() + { + DispatcherUnhandledException += OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException; + TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; + } - } + /// 返回 null 可允许多开;默认限制同一登录会话中的应用实例。 + public virtual string? GetMutexName() => @"Local\Store.Management"; + protected override void OnStartup(StartupEventArgs e) + { + try + { + //string? name = GetMutexName(); + //if (!string.IsNullOrWhiteSpace(name)) + //{ + // _mutex = new Mutex(true, name, out _ownsMutex); + // if (!_ownsMutex) + // { + // MessageBox.Show("程序已在运行。", Config.AppName, MessageBoxButton.OK, MessageBoxImage.Information); + // Shutdown(); + // return; + // } + //} + FolderConfig.Initialize(); + base.OnStartup(e); + ApplicationLogService.Initialize(); + NLogger.Instance.Info($"软件启动:{Config.AppFullName},版本:{Config.GetFullVersionString()},会话:{Config.SessionID},启动时间:{Config.AppStartTime:O},Windows:{Config.WindowsVersion}"); + DISource.Resolver = Resolve; + } + catch (Exception ex) { Fatal(ex); } + } - protected override void OnStartup(StartupEventArgs e) - { - DISource.Resolver = Resolve; - base.OnStartup(e); - } + protected override void OnInitialized() + { + base.OnInitialized(); + IsAppInitialized = true; + NLogger.Instance.Info("软件初始化成功,进入主页面"); + } - protected override void OnInitialized() - { - (Container as IContainerExtension).RegisterDialog(); - base.OnInitialized(); - } + protected override void RegisterRequiredTypes(IContainerRegistry registry) + { + base.RegisterRequiredTypes(registry); + registry.RegisterDialogWindow(); + } + private object Resolve(Type type, string name) + { + if (type == null || Container is null) return null!; + return name != null ? Container.Resolve(type, name) : Container.Resolve(type); + } - private object Resolve(Type type, string name) - { - if (type == null || Container is null) - { - return null; - } + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + e.Handled = true; + Fatal(e.Exception); + } - if (name != null) - { - return Container.Resolve(type, name); - } + private void OnDomainUnhandledException(object sender, UnhandledExceptionEventArgs e) + { + Exception exception = e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString()); + // CLR 终止期间同步记录,不等待 UI 线程。 + if (e.IsTerminating) ApplicationLogService.WriteFatal(exception, "AppDomain.UnhandledException"); + else Fatal(exception); + } - return Container.Resolve(type); - } + private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + ApplicationLogService.WriteFatal(e.Exception, "TaskScheduler.UnobservedTaskException"); + e.SetObserved(); + } + + /// 先同步保存报告,再通知订阅者并关闭应用。 + public static void Fatal(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + string? path = ApplicationLogService.WriteFatal(exception, "Fatal"); + if (Current is not FrameworkApplication app || Interlocked.Exchange(ref app._fatalHandling, 1) != 0) return; + foreach (Action handler in FatalErrorRaised?.GetInvocationList() ?? []) + { + try { handler(exception); } + catch (Exception ex) { ApplicationLogService.WriteFatal(ex, "FatalErrorRaised"); } + } + void ShowErrorAndShutdown() + { + try + { + MessageBox.Show($"软件发生致命错误,即将关闭。\n{exception.Message}\n\n日志:{path ?? "写入失败,请查看调试输出"}", + Config.AppName, MessageBoxButton.OK, MessageBoxImage.Error); + } + finally { TotalShutdown(-1); } + } + if (app.Dispatcher.CheckAccess()) ShowErrorAndShutdown(); + else if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke((Action)ShowErrorAndShutdown); + } + + /// 通过 WPF 正常退出,执行退出清理。 + public static void TotalShutdown(int exitCode = 0) + { + if (Current is not FrameworkApplication app || Interlocked.Exchange(ref app._shutdownRequested, 1) != 0) return; + ShuttingDown = true; + if (app.Dispatcher.CheckAccess()) app.Shutdown(exitCode); + else if (!app.Dispatcher.HasShutdownStarted) app.Dispatcher.BeginInvoke((Action)(() => app.Shutdown(exitCode))); + } + + protected override void OnExit(ExitEventArgs e) + { + ShuttingDown = true; + try { base.OnExit(e); } + finally + { + DispatcherUnhandledException -= OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException -= OnDomainUnhandledException; + TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException; + DISource.Resolver = null!; + try { if (_ownsMutex) _mutex?.ReleaseMutex(); } + finally + { + _mutex?.Dispose(); + try { NLogger.Instance.Info($"软件关闭,退出码:{e.ApplicationExitCode}"); } + finally { LogManager.Shutdown(); } + } + } + } } diff --git a/Source/Store.Management.Framework/Resources/HandyControlBasic.xaml b/Source/Store.Management.Framework/Resources/HandyControlBasic.xaml new file mode 100644 index 0000000..3f00aa9 --- /dev/null +++ b/Source/Store.Management.Framework/Resources/HandyControlBasic.xaml @@ -0,0 +1,15 @@ + + 28 + 10 + 10,5 + 8,0 + 2 + 30 + 24 + 20 + 16 + 12 + diff --git a/Source/Store.Management.Framework/Services/ApplicationLogService.cs b/Source/Store.Management.Framework/Services/ApplicationLogService.cs new file mode 100644 index 0000000..30f40e4 --- /dev/null +++ b/Source/Store.Management.Framework/Services/ApplicationLogService.cs @@ -0,0 +1,60 @@ +using MAX.Framework.Logger; +using NLog; +using NLog.Config; +using NLog.Targets; +using Store.Management.Framework.Configs; +using System.Diagnostics; +using System.IO; +using System.Text; + +namespace Store.Management.Framework.Services; + +/// 默认运行日志与不依赖容器的同步崩溃报告。 +public static class ApplicationLogService +{ + public static void Initialize() + { + // 在加载文件配置前注入目录,配置自动重载时仍可读取。 + GlobalDiagnosticsContext.Set("LoggingFolder", FolderConfig.LoggingFolder); + // 尊重宿主提供的 NLog 配置。 + if (LogManager.Configuration != null) return; + var target = new FileTarget("application") + { + FileName = Path.Combine(FolderConfig.LoggingFolder, "${shortdate}.log"), + Layout = "${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}", + Encoding = Encoding.UTF8, + MaxArchiveDays = 30 + }; + var configuration = new LoggingConfiguration(); + configuration.AddRule(LogLevel.Debug, LogLevel.Fatal, target); + LogManager.Configuration = configuration; + } + + /// 报告路径;所有目录均写入失败时返回 null。 + public static string? WriteFatal(Exception exception, string source) + { + ArgumentNullException.ThrowIfNull(exception); + string id = $"{DateTime.UtcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}"; + string report = $"Id: {id}\nTime: {DateTimeOffset.Now:O}\nSource: {source}\nApp: {Config.AppFullName}\nVersion: {Config.Version}\nSession: {Config.SessionID}\nStarted: {Config.AppStartTime:O}\nWindows: {Config.WindowsVersion}\n{exception}"; + Debug.WriteLine(report); + string? savedPath = null; + foreach (string directory in new[] { FolderConfig.LoggingFolder, Path.Combine(Path.GetTempPath(), FolderConfig.AppName, "Logs") }) + { + try + { + Directory.CreateDirectory(directory); + savedPath = Path.Combine(directory, $"fatal-{id}.log"); + File.WriteAllText(savedPath, report, Encoding.UTF8); + break; + } + catch (Exception ex) { savedPath = null; Debug.WriteLine(ex); } + } + try + { + LogManager.GetLogger("GlobalLogger").Fatal(exception, "{0}: {1}; Report: {2}", source, id, savedPath); + LogManager.Flush(TimeSpan.FromSeconds(2)); + } + catch (Exception ex) { Debug.WriteLine(ex); } + return savedPath; + } +} diff --git a/Source/Store.Management.Framework/Services/SettingsServiceBase.cs b/Source/Store.Management.Framework/Services/SettingsServiceBase.cs new file mode 100644 index 0000000..2c9586d --- /dev/null +++ b/Source/Store.Management.Framework/Services/SettingsServiceBase.cs @@ -0,0 +1,99 @@ +using System.IO; +using Newtonsoft.Json; +using Prism.Mvvm; +using Store.Management.Framework.Configs; + +namespace Store.Management.Framework.Services; + +public interface ISettingsServiceBase where T : class +{ + T Model { get; } + string FilePath { get; } + T GetDefaultModel(); + void LoadSettings(); + Task LoadSettingsAsync(); + void SaveSettings(); + Task SaveSettingsAsync(); +} + +/// +/// JSON 配置服务。缺失文件保留当前配置,加载失败保留旧模型并抛出异常。 +/// 保存通过同目录临时文件替换目标;模型变更应在所属 UI 线程调用。 +/// +public abstract class JsonSettingsServiceBase : BindableBase, ISettingsServiceBase where T : class +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private T? _model; + /// 默认按模型类型名保存到用户配置目录,派生类可覆盖文件名或完整路径。 + public virtual string FilePath => FolderConfig.GetSettingsFilePath(typeof(T).Name + ".json"); + public abstract T GetDefaultModel(); + public T Model + { + get => _model ??= GetDefaultModel() ?? throw new InvalidOperationException("默认配置不能为空。"); + protected set + { + ArgumentNullException.ThrowIfNull(value); + SetProperty(ref _model, value); + } + } + + public void LoadSettings() + { + T? loaded = LoadModelAsync().GetAwaiter().GetResult(); + if (loaded is not null) Model = loaded; + } + public void SaveSettings() => SaveSettingsAsync().GetAwaiter().GetResult(); + + public async Task LoadSettingsAsync() + { + T? loaded = await LoadModelAsync(); + if (loaded is not null) Model = loaded; + } + + private async Task LoadModelAsync() + { + T? loaded = null; + await _gate.WaitAsync().ConfigureAwait(false); + try + { + string path = GetFullPath(); + try + { + string json = await File.ReadAllTextAsync(path).ConfigureAwait(false); + loaded = JsonConvert.DeserializeObject(json) + ?? throw new JsonSerializationException($"配置文件不能为 null:{path}"); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + } + finally { _gate.Release(); } + return loaded; + } + + public async Task SaveSettingsAsync() + { + // 在首次等待前读取模型,避免在后台线程初始化默认模型。 + string json = JsonConvert.SerializeObject(Model, Formatting.Indented); + await _gate.WaitAsync().ConfigureAwait(false); + string? temporaryPath = null; + try + { + string path = GetFullPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + await File.WriteAllTextAsync(temporaryPath, json).ConfigureAwait(false); + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + try { if (temporaryPath is not null && File.Exists(temporaryPath)) File.Delete(temporaryPath); } + finally { _gate.Release(); } + } + } + + private string GetFullPath() + { + ArgumentException.ThrowIfNullOrWhiteSpace(FilePath); + return Path.GetFullPath(FilePath); + } +} diff --git a/Source/Store.Management.Framework/Store.Management.Framework.csproj b/Source/Store.Management.Framework/Store.Management.Framework.csproj index 21cc889..b9a0686 100644 --- a/Source/Store.Management.Framework/Store.Management.Framework.csproj +++ b/Source/Store.Management.Framework/Store.Management.Framework.csproj @@ -1,14 +1,16 @@ - + - net10.0-windows - enable + net8.0-windows true - enable - + + + + + diff --git a/Source/Store.Management.Framework/Utilities/AsyncEvent.cs b/Source/Store.Management.Framework/Utilities/AsyncEvent.cs new file mode 100644 index 0000000..4749044 --- /dev/null +++ b/Source/Store.Management.Framework/Utilities/AsyncEvent.cs @@ -0,0 +1,32 @@ +namespace Store.Management.Framework.Utilities; + +/// 按订阅顺序等待异步处理器;异常向调用者传播并停止后续处理。 +public sealed class AsyncEvent where TEventArgs : EventArgs +{ + private readonly object _gate = new(); + private readonly List> _handlers = []; + + public void Subscribe(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_gate) { _handlers.Add(callback); } + } + + public void Unsubscribe(Func callback) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_gate) + { + int index = _handlers.LastIndexOf(callback); + if (index >= 0) _handlers.RemoveAt(index); + } + } + + public async Task InvokeAsync(object? sender, TEventArgs eventArgs) + { + ArgumentNullException.ThrowIfNull(eventArgs); + Func[] snapshot; + lock (_gate) { snapshot = _handlers.ToArray(); } + foreach (var callback in snapshot) await callback(sender, eventArgs); + } +} diff --git a/Source/Store.Management.Framework/Utilities/DisposeAction.cs b/Source/Store.Management.Framework/Utilities/DisposeAction.cs new file mode 100644 index 0000000..4c75b94 --- /dev/null +++ b/Source/Store.Management.Framework/Utilities/DisposeAction.cs @@ -0,0 +1,15 @@ +namespace Store.Management.Framework.Utilities; + +/// 在释放时执行一次清理操作,可安全地重复或并发释放。 +public sealed class DisposeAction : IDisposable +{ + private Action? _action; + + public DisposeAction(Action action) + { + ArgumentNullException.ThrowIfNull(action); + _action = action; + } + + public void Dispose() => Interlocked.Exchange(ref _action, null)?.Invoke(); +} diff --git a/Source/Store.Management/App.xaml b/Source/Store.Management/App.xaml index a0edb54..1d90db3 100644 --- a/Source/Store.Management/App.xaml +++ b/Source/Store.Management/App.xaml @@ -1,9 +1,22 @@ - - - + xmlns:mf="clr-namespace:Store.Management.Framework;assembly=Store.Management.Framework"> + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/Store.Management/App.xaml.cs b/Source/Store.Management/App.xaml.cs index b3f8bba..8b57a31 100644 --- a/Source/Store.Management/App.xaml.cs +++ b/Source/Store.Management/App.xaml.cs @@ -1,29 +1,29 @@ -using Prism.Ioc; +using Prism.Ioc; +using MAX.Notification; +using Store.Management.Notifications; using Store.Management.Framework; -using System.Configuration; -using System.Data; +using Store.Management.Services; +using Store.Management.ViewModels; +using Store.Management.Views; using System.Windows; - -namespace Store.Management +namespace Store.Management; +public partial class App : FrameworkApplication { - /// - /// Interaction logic for App.xaml - /// - public partial class App : FrameworkApplication - { - protected override void OnStartup(StartupEventArgs e) - { - base.OnStartup(e); - } - protected override Window CreateShell() - { - throw new NotImplementedException(); - } - - protected override void RegisterTypes(IContainerRegistry containerRegistry) - { - - } - } - + protected override Window CreateShell() => Container.Resolve(); + protected override void RegisterTypes(IContainerRegistry registry) + { + registry.RegisterSingleton(); + registry.Register(); + registry.RegisterSingleton(); + registry.Register(provider => new NotificationPublisher( + new PrismNotificationServiceProvider(provider))); + // 显式提供处理器集合,展示一条通知分发给多个业务模块。 + registry.Register>>(provider => + new INotificationHandler[] + { + new ProductCacheHandler(provider.Resolve()), + new ProductAuditHandler(provider.Resolve()) + }); + registry.RegisterDialog(); + } } diff --git a/Source/Store.Management/MainWindow.xaml b/Source/Store.Management/MainWindow.xaml index b76693d..e900502 100644 --- a/Source/Store.Management/MainWindow.xaml +++ b/Source/Store.Management/MainWindow.xaml @@ -1,12 +1,13 @@ - - - - - + + + \ No newline at end of file diff --git a/Source/Store.Management/MainWindow.xaml.cs b/Source/Store.Management/MainWindow.xaml.cs index 99902b3..028f5a1 100644 --- a/Source/Store.Management/MainWindow.xaml.cs +++ b/Source/Store.Management/MainWindow.xaml.cs @@ -1,24 +1,6 @@ -using System.Text; using System.Windows; -using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; -using System.Windows.Input; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; - -namespace Store.Management +namespace Store.Management; +public partial class MainWindow : Window { - /// - /// Interaction logic for MainWindow.xaml - /// - public partial class MainWindow : Window - { - public MainWindow() - { - InitializeComponent(); - } - } -} \ No newline at end of file + public MainWindow() => InitializeComponent(); +} diff --git a/Source/Store.Management/NLog.config b/Source/Store.Management/NLog.config new file mode 100644 index 0000000..ca749a6 --- /dev/null +++ b/Source/Store.Management/NLog.config @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/Store.Management/Notifications/NotificationExample.cs b/Source/Store.Management/Notifications/NotificationExample.cs new file mode 100644 index 0000000..6edda1f --- /dev/null +++ b/Source/Store.Management/Notifications/NotificationExample.cs @@ -0,0 +1,54 @@ +using MAX.Notification; +using Prism.Ioc; + +namespace Store.Management.Notifications; + +/// 保存示例处理结果,由发布方等待完成后读取。 +public sealed class NotificationExampleState +{ + private readonly object gate = new(); + private readonly List entries = []; + + public void Record(string message) + { + lock (gate) + { + entries.Add(message); + if (entries.Count > 10) entries.RemoveAt(0); + } + } + + public string GetHistory() + { + lock (gate) return string.Join(Environment.NewLine, entries); + } +} + +/// 模拟模块一:商品保存后刷新缓存。 +public sealed class ProductCacheHandler(NotificationExampleState state) + : INotificationHandler +{ + public Task Handle(ProductSavedNotification notification, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + state.Record($"缓存模块:已刷新商品「{notification.ProductName}」。"); + return Task.CompletedTask; + } +} + +/// 模拟模块二:异步记录操作日志。 +public sealed class ProductAuditHandler(NotificationExampleState state) + : INotificationHandler +{ + public async Task Handle(ProductSavedNotification notification, CancellationToken cancellationToken) + { + await Task.Delay(200, cancellationToken).ConfigureAwait(false); + state.Record($"审计模块:已记录商品「{notification.ProductName}」的保存操作。"); + } +} + +/// 将标准服务解析转交给当前 Prism 容器。 +internal sealed class PrismNotificationServiceProvider(IContainerProvider container) : IServiceProvider +{ + public object? GetService(Type serviceType) => container.Resolve(serviceType); +} diff --git a/Source/Store.Management/Notifications/ProductSavedNotification.cs b/Source/Store.Management/Notifications/ProductSavedNotification.cs new file mode 100644 index 0000000..be28a06 --- /dev/null +++ b/Source/Store.Management/Notifications/ProductSavedNotification.cs @@ -0,0 +1,6 @@ +using MAX.Notification; + +namespace Store.Management.Notifications; + +/// 商品保存后发布的业务通知;不持有页面或控件实例。 +public sealed record ProductSavedNotification(string ProductName) : INotification; diff --git a/Source/Store.Management/Services/DependencyProbes.cs b/Source/Store.Management/Services/DependencyProbes.cs new file mode 100644 index 0000000..11555dd --- /dev/null +++ b/Source/Store.Management/Services/DependencyProbes.cs @@ -0,0 +1,4 @@ +namespace Store.Management.Services; +/// 通过实例标识观察容器生命周期。 +public sealed class SingletonProbe { public Guid InstanceId { get; } = Guid.NewGuid(); } +public sealed class TransientProbe { public Guid InstanceId { get; } = Guid.NewGuid(); } diff --git a/Source/Store.Management/Store.Management.csproj b/Source/Store.Management/Store.Management.csproj index ba6cccc..496f6df 100644 --- a/Source/Store.Management/Store.Management.csproj +++ b/Source/Store.Management/Store.Management.csproj @@ -2,7 +2,6 @@ WinExe - net10.0-windows enable enable true @@ -10,6 +9,13 @@ + + + + + + PreserveNewest + diff --git a/Source/Store.Management/ViewModels/FrameworkTestDialogViewModel.cs b/Source/Store.Management/ViewModels/FrameworkTestDialogViewModel.cs new file mode 100644 index 0000000..b700d1b --- /dev/null +++ b/Source/Store.Management/ViewModels/FrameworkTestDialogViewModel.cs @@ -0,0 +1,23 @@ +using MAX.Framework.Controls; +using Prism.Services.Dialogs; +namespace Store.Management.ViewModels; +public sealed class FrameworkTestDialogViewModel : ConfirmDialogViewModel +{ + private string message = ""; + public FrameworkTestDialogViewModel() => Title = "框架弹窗测试"; + public string Message { get => message; set => SetProperty(ref message, value); } + public override void OnDialogOpened(IDialogParameters parameters) + { + Message = parameters.GetValue("message") ?? ""; + ErrorMsg = ""; + } + protected override DialogResult Confirm() + { + if (string.IsNullOrWhiteSpace(Message)) + { + ErrorMsg = "返回内容不能为空。"; + return null!; // 基类约定:null 表示保持弹窗打开。 + } + return new DialogResult(ButtonResult.OK, new DialogParameters { { "message", Message.Trim() } }); + } +} diff --git a/Source/Store.Management/ViewModels/FrameworkTestPageViewModel.cs b/Source/Store.Management/ViewModels/FrameworkTestPageViewModel.cs new file mode 100644 index 0000000..f2ec28a --- /dev/null +++ b/Source/Store.Management/ViewModels/FrameworkTestPageViewModel.cs @@ -0,0 +1,97 @@ +using Prism.Commands; +using MAX.Notification; +using Store.Management.Notifications; +using Prism.Ioc; +using Prism.Mvvm; +using Prism.Services.Dialogs; +using Store.Management.Services; +using Store.Management.Views; +namespace Store.Management.ViewModels; +public sealed class FrameworkTestPageViewModel : BindableBase +{ + private readonly IContainerProvider container; + private readonly SingletonProbe singleton; + private readonly TransientProbe transient; + private readonly IDialogService dialogs; + private readonly IPublisher publisher; + private readonly NotificationExampleState notificationState; + private string productName = "示例商品"; + private string notificationResult = "输入商品名称,发布后查看两个模块的处理结果。"; + private string iocResult = "点击按钮查看实例 ID 与测试结果。"; + private string dialogInput = "来自主页面的测试参数"; + private string dialogResult = "等待弹窗返回。"; + public FrameworkTestPageViewModel(IContainerProvider container, SingletonProbe singleton, TransientProbe transient, IDialogService dialogs, + IPublisher publisher, NotificationExampleState notificationState) + { + this.container = container; + this.singleton = singleton; + this.transient = transient; + this.dialogs = dialogs; + this.publisher = publisher; + this.notificationState = notificationState; + PublishNotificationCommand = new AsyncDelegateCommand(PublishNotificationAsync); + TestIocCommand = new DelegateCommand(TestIoc); + ShowModalCommand = new DelegateCommand(() => OpenDialog(true)); + ShowModelessCommand = new DelegateCommand(() => OpenDialog(false)); + } + public DelegateCommand TestIocCommand { get; } + public AsyncDelegateCommand PublishNotificationCommand { get; } + public string ProductName { get => productName; set => SetProperty(ref productName, value); } + public string NotificationResult { get => notificationResult; private set => SetProperty(ref notificationResult, value); } + + private async Task PublishNotificationAsync() + { + if (string.IsNullOrWhiteSpace(ProductName)) + { + NotificationResult = "请输入商品名称。"; + return; + } + try + { + NotificationResult = "正在等待所有通知处理器完成……"; + await publisher.Publish(new ProductSavedNotification(ProductName.Trim())); + // 回到页面的 UI 上下文后更新绑定;业务处理器不访问 ViewModel。 + NotificationResult = $"通知处理完成(显示最近 10 条记录):\n{notificationState.GetHistory()}"; + } + catch (Exception ex) + { + NotificationResult = $"通知处理失败:{ex.GetBaseException().Message}"; + } + } + public DelegateCommand ShowModalCommand { get; } + public DelegateCommand ShowModelessCommand { get; } + public string IocResult { get => iocResult; private set => SetProperty(ref iocResult, value); } + public string DialogInput { get => dialogInput; set => SetProperty(ref dialogInput, value); } + public string DialogResult { get => dialogResult; private set => SetProperty(ref dialogResult, value); } + private void TestIoc() + { + try + { + var resolved = container.Resolve(); + var first = container.Resolve(); + var second = container.Resolve(); + bool passed = ReferenceEquals(singleton, resolved) && !ReferenceEquals(transient, first) && !ReferenceEquals(first, second); + IocResult = $"{(passed ? "通过" : "失败")}\n构造注入单例:{singleton.InstanceId}\n再次解析单例:{resolved.InstanceId}\n构造注入瞬态:{transient.InstanceId}\n解析瞬态 A:{first.InstanceId}\n解析瞬态 B:{second.InstanceId}"; + } + catch (Exception ex) { IocResult = $"IoC 测试失败:{ex.Message}"; } + } + private void OpenDialog(bool modal) + { + if (string.IsNullOrWhiteSpace(DialogInput)) + { + DialogResult = "请输入测试参数后再打开弹窗。"; + return; + } + try + { + var parameters = new DialogParameters { { "message", DialogInput.Trim() } }; + DialogResult = "弹窗已打开,等待操作。"; + void OnClosed(IDialogResult result) => DialogResult = result.Result == ButtonResult.OK + ? $"确认:{result.Parameters.GetValue("message")}" : $"弹窗已关闭,结果:{result.Result}"; + if (modal) dialogs.ShowDialog(nameof(FrameworkTestDialog), parameters, OnClosed); + else dialogs.Show(nameof(FrameworkTestDialog), parameters, OnClosed); + } + catch (Exception ex) { DialogResult = $"打开弹窗失败:{ex.GetBaseException().Message}"; } + } +} + diff --git a/Source/Store.Management/Views/FrameworkTestDialog.xaml b/Source/Store.Management/Views/FrameworkTestDialog.xaml new file mode 100644 index 0000000..2b59b79 --- /dev/null +++ b/Source/Store.Management/Views/FrameworkTestDialog.xaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + +