diff --git a/.agents/rules/wpf-xaml.md b/.agents/rules/wpf-xaml.md index a12b8df..d1c0d9f 100644 --- a/.agents/rules/wpf-xaml.md +++ b/.agents/rules/wpf-xaml.md @@ -60,6 +60,9 @@ DataContext="{core:DISource Type=local:DialogViewModel}"> **`BindingProxy` 映射关系**:`Data="{Binding}"` 把 ViewModel 存到 `BindingProxy.Data`。引用时 `Path=Data.SelectedItem` 中 `Data` 对应 `BindingProxy.Data`(即 ViewModel)。 +### 文字只读显示 +尽量使用 `TextBlock` 而不是 `Label` + ### DataTemplate(CellTemplate)中 Command 用 CommandProxy,行数据用 `{Binding}`: @@ -262,6 +265,24 @@ C# 侧:`"#FF5733".toColor()`、`"#80000000".toColor()`。 ``` +## 顺序排列与间距布局 + +**页面排版需要横向、纵向顺序排列或简单自动换行时,优先使用 `wpf:SpacingStackPanel`。** 普通堆叠布局通过 `Orientation` 指定方向,需要换行时设置 `ChildWrapping="True"`。 + +- 优先用 `HorizontalSpacing` / `VerticalSpacing` 统一控制子项间距,避免逐项设置重复的 `Margin`;容器外边距和个别元素的特殊偏移仍按需使用 `Margin`。 +- 需要统一子项尺寸或对齐时,按需设置 `ItemWidth`、`ItemHeight`、`ItemHorizontalAlignment`、`ItemVerticalAlignment`。 +- “文字 + 简单内容”可由 `wpf:TitleContent` 组成,再用 `wpf:SpacingStackPanel` 排列多个字段。 +- 需要行列划分、跨行跨列、重叠或虚拟化时,使用满足这些要求的布局容器,不强行替换为 `SpacingStackPanel`。 +- 属性及用法见 [max-common-wpf SpacingStackPanel 参考](../skills/max-common-wpf/references/api.md#controlsspacingstackpanelcs)。 + +## 文字与简单内容布局 + +**页面排版遇到“说明文字 + 简单内容”(如输入框、选择器、按钮等输入或点击控件)时,优先使用 `wpf:TitleContent`。** 将说明文字放在 `Title`,交互控件作为内容,避免为同一组合另用 `TextBlock` 配合 `Grid` / `StackPanel` 手工拼接。 + +- 按布局设置 `Placement`;左侧标题表单优先使用 `Placement="Left"`,并按需设置 `TitleWidth`、`TitleVerticalAlignment="Center"` 和 `LeftTitlePadding`,保持同组字段对齐。 +- 内容控件仍自行配置绑定、命令、输入长度和可访问名称;`TitleContent` 只负责标题与内容的布局。 +- 属性及用法见 [max-common-wpf TitleContent 参考](../skills/max-common-wpf/references/api.md#controlstitlecontentcs)。 + ## 布局注释 主要布局分块/区域前加 `` 注释标明用途。复杂视图(多面板、多 Tab、对话框)必须注释;简单单控件视图可省略: @@ -302,48 +323,34 @@ C# 侧:`"#FF5733".toColor()`、`"#80000000".toColor()`。 ## 枚举绑定 -### EnumDisplayConverter(XAML) +**页面的枚举选项绑定优先使用 `EnumBindingMarkup`(`EnumBindingMarkupExtension`)生成 ItemsSource,配合 `EnumBindingExtension.SelectedEnum` 绑定选中的枚举值。** 不要仅为列出枚举值,在 ViewModel 中额外定义 `Enum.GetValues` 列表或在 XAML 中硬编码选项。 + +- 显示名优先使用 MAX.Common.WPF 自带的 `EnumDisplayConverter`,显式传入标记扩展的 `Converter`;未传入时默认显示枚举成员名称。 +- `DisplayMemberPath="Display"` 用于显示名称,`SelectedValuePath="Value"` 对应枚举值本身。 +- 系统初始化、视图加载前配置一次 `EnumDisplayConverter.EnumDisplayManager = new EnumDisplayManagerWPF(null);`;已有自定义显示管理器时复用现有配置。 +- 仅显示单个枚举值的文本时,直接绑定该属性并使用 `EnumDisplayConverter`,无需生成选项列表。 + +### ComboBox 绑定枚举 + +`xmlns:wpf="MAX.WPF"`,`xmlns:local` 指向业务枚举命名空间: ```xml - + - - + + + + ``` -### ComboBox 绑定枚举(推荐:ViewModel 提供列表) - -```csharp -// ViewModel -public IReadOnlyList StatusList { get; } = Enum.GetValues(typeof(MyStatus)).Cast().ToList(); - -public MyStatus SelectedStatus -{ - get => GetProperty(() => SelectedStatus); - set => SetProperty(() => SelectedStatus, value); -} -``` - -```xml - - - - - - - -``` - -### C# 获取枚举显示文本 - -```csharp -string displayText = MyStatus.Active.GetEnumDisplay(); -``` - -EnumDisplayService 内部实现和自定义逻辑详见 `reference-enum-display` skill。 +初始化命名空间、Description 显示、空选项等完整用法见 [max-common-wpf 枚举绑定参考](../skills/max-common-wpf/references/api.md#markupextensionsenumbindingmarkupextensioncs)。自定义枚举显示管理器见 `reference-enum-display` skill。 ## MarkupExtension 示例 diff --git a/.agents/skills/add-service/SKILL.md b/.agents/skills/add-service/SKILL.md index 5909237..5d9874f 100644 --- a/.agents/skills/add-service/SKILL.md +++ b/.agents/skills/add-service/SKILL.md @@ -24,7 +24,7 @@ disable-model-invocation: false ```csharp using MAX.Core.DependencyInjections; -namespace PipeReviewReport.<模块名>.Services; +namespace 项目.<模块名>.Services; public interface IXxxService { @@ -43,7 +43,7 @@ internal class XxxService : IXxxService, ISingletonDependency using MAX.Core.DependencyInjections; using MAX.Core.Interfaces; -namespace PipeReviewReport.<模块名>.Services; +namespace 项目.<模块名>.Services; public interface IXxxService { } diff --git a/.agents/skills/max-common-wpf/SKILL.md b/.agents/skills/max-common-wpf/SKILL.md index 764a78b..02dcd5b 100644 --- a/.agents/skills/max-common-wpf/SKILL.md +++ b/.agents/skills/max-common-wpf/SKILL.md @@ -18,6 +18,8 @@ description: 在 Windows WPF 项目中接入和使用 MAX.Common.WPF 的控件 按任务读取 [功能索引](references/features.md)。具体命名空间、属性和方法在 [常用 API 声明](references/api.md) 中按类型搜索。声明摘录没有完整方法体;行为不明确时查看当前安装包 XML 文档、反射或可用源码,再以编译和运行验证。消费项目没有原仓库也应能使用本指南。 +标题布局与间距布局分别参见 [TitleContent 用法](references/api.md#controlstitlecontentcs) 和 [SpacingStackPanel 用法](references/api.md#controlsspacingstackpanelcs)。 + ## XAML 资源入口 程序集声明了 `xmlns:wpf="MAX.WPF"`。控件样式和共享转换器位于 `Resources/Generic.xaml`,不要猜成 `Themes/Generic.xaml`。需要这些资源时,在现有 Application.Resources 的 MergedDictionaries 中合并一次: @@ -26,12 +28,14 @@ description: 在 Windows WPF 项目中接入和使用 MAX.Common.WPF 的控件 ``` -页面最小示例(绑定属性由页面的 DataContext 提供): +## 页面最小示例 + +绑定属性由页面的 DataContext 提供: ```xml + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:wpf="MAX.WPF"> @@ -55,18 +59,11 @@ description: 在 Windows WPF 项目中接入和使用 MAX.Common.WPF 的控件 - 虚拟化依赖有限可视区域、滚动宿主和 ItemsPanel 配置。`VirtualizingItemsControl` 默认使用 VirtualizingStackPanel,需要换行时显式设置 VirtualizingWrapPanel。不能仅添加面板便承诺大数据性能。 - 通用命令在 `MAX.Common.Commands`,不是 WPF.Commands。MethodCommand / AsyncMethodCommand 的 T 需引用类型或可空值类型,状态变化手动 RaiseCanExecuteChanged;异步命令委托需自行处理业务异常。 -## 枚举绑定示例 +## 枚举绑定 -已有 `xmlns:local` 指向业务枚举命名空间、`SelectedStatus` 是 `Status` 属性时: +枚举显示优先使用 MAX.Common.WPF 自带的 `EnumDisplayConverter`,并通过 `EnumBindingMarkupExtension.Converter` 显式传入;未设置转换器时只显示枚举成员名称。系统初始化时、视图加载前配置一次 `EnumDisplayConverter.EnumDisplayManager = new EnumDisplayManagerWPF(null);`;已有自定义管理器时复用现有配置。 -```xml - -``` - -允许空选项时使用 AllowNull 并让业务属性可空;自定义显示通过扩展的 Converter 提供,勿假设自动读取所有枚举描述标记。 +初始化代码、Description 显示示例及选中值绑定见 [枚举绑定用法](references/api.md#markupextensionsenumbindingmarkupextensioncs)。 ## 验证与交付 diff --git a/.agents/skills/max-common-wpf/references/api.md b/.agents/skills/max-common-wpf/references/api.md index dfd7fa9..37ca037 100644 --- a/.agents/skills/max-common-wpf/references/api.md +++ b/.agents/skills/max-common-wpf/references/api.md @@ -2,12 +2,18 @@ 来源:MAX.Common.WPF,2026-10-27 工作区源码快照。 -按下方主题或文件名搜索。仅摘录选定文件的 public / protected 声明;省略方法体、初始化表达式与 XML 注释。这是定位参考,不是可直接编译的 C#,不保证包含嵌套类型的完整可见性上下文。行为说明见 SKILL.md;目标包版本不同时以实际包为准。 +按下方主题或文件名搜索。API 代码块仅摘录选定文件的 public / protected 声明;省略方法体、初始化表达式与 XML 注释。TitleContent 与 SpacingStackPanel 另附属性说明和 XAML 用法。这是定位参考,不是可直接编译的 C#,不保证包含嵌套类型的完整可见性上下文。通用集成约束见 SKILL.md;目标包版本不同时以实际包为准。 目录:控件与布局、枚举、视图与绑定、线程与防抖、虚拟化与转换器 ## 控件与布局 +以下 TitleContent 与 SpacingStackPanel 示例在视图根元素声明命名空间: + +```xml +xmlns:max="clr-namespace:MAX.Common.WPF.Controls;assembly=MAX.Common.WPF" +``` + ### Controls/NumberBox.cs 命名空间:`MAX.Common.WPF.Controls` @@ -46,6 +52,55 @@ public Thickness LeftTitlePadding public TitleContent() ``` +`TitleContent` 是一个"标题 + 内容"布局控件:在 `Content` 周围额外渲染一个 `Title`,并可通过 `Placement` 决定标题相对内容的位置,同时支持标题宽度、对齐方式与内边距的精细控制。 + +| 属性 | 类型 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | +| `Content` | `object` | — | 继承自 `ContentControl`,主内容 | +| `Title` | `object` | `null` | 标题内容(可放字符串、任意 UIElement) | +| `Placement` | `TitlePlacementType` | `Top` | 标题相对内容的位置 | +| `TitleWidth` | `double` | `NaN` | 标题宽度;`NaN` 表示自动(Auto) | +| `TitleHorizontalAlignment` | `HorizontalAlignment` | `Left` | 标题在自身区域内的水平对齐 | +| `TitleVerticalAlignment` | `VerticalAlignment` | `Top` | 标题在自身区域内的垂直对齐 | +| `TopTitlePadding` | `Thickness` | `(7,2,0,1)` | `Placement=Top` 时标题的内边距 | +| `LeftTitlePadding` | `Thickness` | `(0,0,6,0)` | `Placement=Left` 时标题的内边距 | + +标题位置示例使用 Top 与 Left;其他枚举值以实际程序集为准,不根据 padding 属性名推断。 + +#### 1. 最简用法(标题在上) + +```xml + + + +``` + +#### 2. 标题在左侧 + 固定标题宽度(表单场景) + +```xml + + + +``` + +#### 3. 标题使用复杂 UIElement + +```xml + + + + + + + + + +``` + ### Controls/BoolContent.cs 命名空间:`MAX.Common.WPF.Controls` @@ -74,6 +129,87 @@ public HorizontalAlignment? ItemHorizontalAlignment public VerticalAlignment? ItemVerticalAlignment ``` +> `SpacingStackPanel` 是一个"带间距 + 可选自动换行 + 可选统一子项尺寸"的布局面板,可看作 `StackPanel` 的增强版:既支持 StackPanel 式的单向排列,也支持 `WrapPanel` 式的换行排列,并且可以在**每个子项之间**插入水平/垂直间距。 + +#### 核心属性速查 + +| 属性 | 类型 | 默认值 | 说明 | +| :--- | :--- | :--- | :--- | +| `Orientation` | `Orientation` | `Horizontal` | 排列方向(水平/垂直),影响"主轴"与间距的解释 | +| `ChildWrapping` | `bool` | `false` | 是否自动换行(类似 WrapPanel);`false` 时类似 StackPanel | +| `HorizontalSpacing` | `double` | `NaN`(视为 0) | 子项之间的水平间距;校验要求 `> 0` 或 `NaN` | +| `VerticalSpacing` | `double` | `NaN`(视为 0) | 子项之间的垂直间距;校验要求 `> 0` 或 `NaN` | +| `ItemWidth` | `double` | `NaN`(自动) | 统一子项宽度;示例使用数值宽度,不将其当作 GridLength 的星号尺寸 | +| `ItemHeight` | `double` | `NaN`(自动) | 统一子项高度 | +| `ItemHorizontalAlignment` | `HorizontalAlignment?` | `Stretch` | 强制设置所有子项的 `HorizontalAlignment` | +| `ItemVerticalAlignment` | `VerticalAlignment?` | `Stretch` | 强制设置所有子项的 `VerticalAlignment` | + +> 关键点:`HorizontalSpacing`/`VerticalSpacing` 不是"主轴/交叉轴"间距,而是**屏幕坐标意义上的水平/垂直间距**。当 `Orientation=Horizontal` 时通常用 `HorizontalSpacing` 拉开子项;`Orientation=Vertical` 时通常用 `VerticalSpacing` 拉开子项。 + +#### XAML 基本用法 + +示例沿用本节开头的 max 命名空间声明。 + +##### 1. 水平排列 + 子项水平间距(最常用) + +```xml + + -
账户积分 9,759管管理员
+
29 供应链 — 运营管理工作台
+ +
STORE MANAGEMENT

店铺管理

diff --git a/DesignDemo2/check.cjs b/DesignDemo2/check.cjs new file mode 100644 index 0000000..8513e11 --- /dev/null +++ b/DesignDemo2/check.cjs @@ -0,0 +1,58 @@ +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const html = fs.readFileSync(__dirname + '/index.html', 'utf8'); +const script = html.match(/ + + diff --git a/Source/Store.Management.Api/Authentication/AuthApi.cs b/Source/Store.Management.Api/Authentication/AuthApi.cs index 096d892..25c7211 100644 --- a/Source/Store.Management.Api/Authentication/AuthApi.cs +++ b/Source/Store.Management.Api/Authentication/AuthApi.cs @@ -22,14 +22,7 @@ public sealed class LoginResult 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 +public sealed class AuthApi(ApiTransport transport, TokenSession session) { // 登录和退出串行执行,避免迟到的登录响应覆盖退出操作。 private readonly SemaphoreSlim _gate = new(1, 1); diff --git a/Source/Store.Management.Api/StoreApiClient.cs b/Source/Store.Management.Api/StoreApiClient.cs index ff12723..13629a1 100644 --- a/Source/Store.Management.Api/StoreApiClient.cs +++ b/Source/Store.Management.Api/StoreApiClient.cs @@ -9,8 +9,8 @@ public sealed class StoreApiClient : IDisposable { public TokenSession Session { get; } = new(); public ApiTransport Transport { get; } - public IAuthApi Auth { get; } - public IUserApi Users { get; } + public AuthApi Auth { get; } + public UserApi Users { get; } /// 可选的外部 HTTP 客户端,由调用方管理其生命周期。 public StoreApiClient(ApiClientOptions options, HttpClient? httpClient = null) diff --git a/Source/Store.Management.Api/Users/UserApi.cs b/Source/Store.Management.Api/Users/UserApi.cs index e528c86..2345aec 100644 --- a/Source/Store.Management.Api/Users/UserApi.cs +++ b/Source/Store.Management.Api/Users/UserApi.cs @@ -4,15 +4,8 @@ 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 sealed class UserApi(ApiTransport transport) { public Task> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default) { diff --git a/Source/Store.Management.Framework/FrameworkApplication.cs b/Source/Store.Management.Framework/FrameworkApplication.cs index a794f3d..e0c5f91 100644 --- a/Source/Store.Management.Framework/FrameworkApplication.cs +++ b/Source/Store.Management.Framework/FrameworkApplication.cs @@ -3,6 +3,7 @@ using NLog; using Prism.Autofac; using Prism.Ioc; using Prism.Services.Dialogs; +using Store.Management.Api; using Store.Management.Framework.Configs; using Store.Management.Framework.Extensions; using Store.Management.Framework.Services; @@ -131,7 +132,11 @@ public abstract class FrameworkApplication : PrismApplication protected override void OnExit(ExitEventArgs e) { ShuttingDown = true; - try { base.OnExit(e); } + try + { + try { Container?.Resolve().Dispose(); } + finally { base.OnExit(e); } + } finally { DispatcherUnhandledException -= OnDispatcherUnhandledException; diff --git a/Source/Store.Management.Framework/Resources/FrameworkTheme.xaml b/Source/Store.Management.Framework/Resources/FrameworkTheme.xaml index 096fbfa..5833c71 100644 --- a/Source/Store.Management.Framework/Resources/FrameworkTheme.xaml +++ b/Source/Store.Management.Framework/Resources/FrameworkTheme.xaml @@ -4,6 +4,8 @@ xmlns:f="clr-namespace:Store.Management.Framework.Controls"> + + diff --git a/Source/Store.Management.Framework/Services/LoginService.cs b/Source/Store.Management.Framework/Services/LoginService.cs new file mode 100644 index 0000000..3d4a4e6 --- /dev/null +++ b/Source/Store.Management.Framework/Services/LoginService.cs @@ -0,0 +1,22 @@ +using MAX.Core.DependencyInjection; +using Store.Management.Api; +using Store.Management.Api.Authentication; + +namespace Store.Management.Framework.Services; + +/// 用户登录服务,复用应用 API 客户端的认证会话。 +public interface ILoginService +{ + /// 提交登录;passwordHash 为 UTF-8 密码的 32 位大写 MD5。 + Task LoginAsync(string userName, string passwordHash, CancellationToken cancellationToken = default); +} + +internal sealed class LoginService(StoreApiClient apiClient) : ILoginService, ISingletonDependency +{ + public Task LoginAsync(string userName, string passwordHash, CancellationToken cancellationToken = default) + => apiClient.Auth.LoginAsync(new LoginRequest + { + UserName = userName.Trim(), + Password = passwordHash + }, cancellationToken); +} diff --git a/Source/Store.Management.Framework/Startup.cs b/Source/Store.Management.Framework/Startup.cs new file mode 100644 index 0000000..bd4742e --- /dev/null +++ b/Source/Store.Management.Framework/Startup.cs @@ -0,0 +1,33 @@ +using Autofac; +using MAX.Common.WPF.Converters; +using MAX.Common.WPF.Utilities; +using MAX.Core; +using MAX.Framework.Interfaces; +using Store.Management.Api; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Store.Management.Framework +{ + internal class Startup : IStartup + { + public int Priority => IntPriority.High; + + public void Initialize() + { + EnumDisplayConverter.EnumDisplayManager = new EnumDisplayManagerWPF(null); + } + + public void Register(ContainerBuilder builder) + { + var client = new StoreApiClient(new ApiClientOptions + { + BaseUrl = new Uri("http://localhost:14312/api/") + }); + builder.Register(_ => client).SingleInstance().PreserveExistingDefaults(); + } + } +} diff --git a/Source/Store.Management.Framework/Store.Management.Framework.csproj b/Source/Store.Management.Framework/Store.Management.Framework.csproj index ab453e0..ed0f6db 100644 --- a/Source/Store.Management.Framework/Store.Management.Framework.csproj +++ b/Source/Store.Management.Framework/Store.Management.Framework.csproj @@ -12,6 +12,7 @@ + diff --git a/Source/Store.Management/App.xaml.cs b/Source/Store.Management/App.xaml.cs index aad4075..27a063c 100644 --- a/Source/Store.Management/App.xaml.cs +++ b/Source/Store.Management/App.xaml.cs @@ -2,7 +2,6 @@ using MAX.Notification; using Prism.Ioc; using Prism.Mvvm; using Prism.Services.Dialogs; -using Store.Management.Api; using Store.Management.Framework; using Store.Management.Notifications; using Store.Management.Services; @@ -12,20 +11,6 @@ using System.Windows; namespace Store.Management; public partial class App : FrameworkApplication { - private readonly StoreApiClient? _apiClient; - - public App() { } - - public App(StoreApiClient apiClient) - { - _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); - } - - private StoreApiClient CreateApiClient() => _apiClient ?? new(new ApiClientOptions - { - BaseUrl = new Uri("http://localhost:14312/api/") - }); - protected override Window CreateShell() { if (Environment.GetCommandLineArgs().Contains("--grid-demo")) @@ -71,8 +56,6 @@ public partial class App : FrameworkApplication ViewModelLocationProvider.Register(); containerRegistry.RegisterDialog(); - //registry.RegisterInstance(CreateApiClient()); - // registry.Register(provider => new LoginViewModel(provider.Resolve().Auth)); // registry.RegisterSingleton(); // registry.Register(); // registry.RegisterSingleton(); @@ -89,9 +72,4 @@ public partial class App : FrameworkApplication // registry.RegisterDialog(); } - protected override void OnExit(ExitEventArgs e) - { - //try { Container?.Resolve().Dispose(); } - //finally { base.OnExit(e); } - } } diff --git a/Source/Store.Management/ViewModels/LoginViewModel.cs b/Source/Store.Management/ViewModels/LoginViewModel.cs index c340572..044eebe 100644 --- a/Source/Store.Management/ViewModels/LoginViewModel.cs +++ b/Source/Store.Management/ViewModels/LoginViewModel.cs @@ -1,19 +1,33 @@ -using System.Security.Cryptography; -using System.Text; -using Prism.Commands; +using Prism.Commands; using Prism.Services.Dialogs; -using System.Windows.Input; -using Store.Management.Api.Authentication; using Store.Management.Api.Transport; -using Store.Management.Framework.ViewModels; using Store.Management.Framework.Controls; +using Store.Management.Framework.Services; +using Store.Management.Framework.ViewModels; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography; +using System.Text; +using System.Windows.Input; namespace Store.Management.ViewModels; -/// 登录校验、认证请求和窗口生命周期内的请求取消。 -public sealed class LoginViewModel() : ConfirmDialogViewModel, IDisposable +public enum EEE { - //private readonly IAuthApi _auth = auth ?? throw new ArgumentNullException(nameof(auth)); + [Description("管理员")] + Admin, + [Description("用户")] + User +} +/// 登录校验、认证请求和窗口生命周期内的请求取消。 +public sealed class LoginViewModel(ILoginService loginService) : ConfirmDialogViewModel, IDisposable +{ + private EEE _ee; + public EEE ee + { + get => _ee; + set => SetProperty(ref _ee, value); + } private readonly CancellationTokenSource _lifetime = new(); private string _userName = ""; private string _passwordHash = ""; @@ -73,16 +87,12 @@ public sealed class LoginViewModel() : ConfirmDialogViewModel, IDisposable NotifyStateChanged(); try { - //await _auth.LoginAsync(new LoginRequest - //{ - // UserName = UserName.Trim(), - // Password = _passwordHash - //}, _lifetime.Token); - //if (!_disposed) - //{ - // _authenticated = true; - // OnRequestClose(new DialogResult(ButtonResult.OK)); - //} + await loginService.LoginAsync(UserName, _passwordHash, _lifetime.Token); + if (!_disposed) + { + _authenticated = true; + OnRequestClose(new DialogResult(ButtonResult.OK)); + } } catch (OperationCanceledException) when (_disposed) { diff --git a/Source/Store.Management/Views/LoginWindow.xaml b/Source/Store.Management/Views/LoginWindow.xaml index f67477b..3f8bbe4 100644 --- a/Source/Store.Management/Views/LoginWindow.xaml +++ b/Source/Store.Management/Views/LoginWindow.xaml @@ -5,6 +5,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:Store.Management.ViewModels" + xmlns:wpf="MAX.WPF" Width="520" Height="620" MinWidth="440" @@ -42,32 +43,38 @@ BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1" CornerRadius="8"> - - + (); + Check(ReferenceEquals(productionClient, productionContainer.Resolve()), "自动注册 API 单例"); + Check(productionContainer.Resolve() != null, "自动注册登录服务"); + } + var builder = new ContainerBuilder(); + builder.RegisterInstance(client); + new Store.Management.Framework.DependencyInjection.ApiClientRegistration().Register(builder); + builder.RegisterAssemblyTypes(typeof(Store.Management.Framework.Services.ILoginService).Assembly) + .Where(type => typeof(MAX.Core.DependencyInjection.ISingletonDependency).IsAssignableFrom(type)) + .AsImplementedInterfaces().SingleInstance(); + using var container = builder.Build(); + Check(ReferenceEquals(client, container.Resolve()), "保留测试客户端注册"); + var service = container.Resolve(); + using var vm = new LoginViewModel(service); + vm.UserName = " admin "; + vm.UpdatePassword("wrong"); + vm.LoginCommand.Execute().GetAwaiter().GetResult(); + Check(!client.Session.IsAuthenticated && vm.CanEdit, "登录失败后可以重试"); + ButtonResult? result = null; + vm.RequestClose += response => result = response.Result; + vm.UpdatePassword(" secret "); + vm.LoginCommand.Execute().GetAwaiter().GetResult(); + Check(client.Session.IsAuthenticated && result == ButtonResult.OK, "Service 登录成功并通知关闭"); + using var closingVm = new LoginViewModel(service); + closingVm.UserName = "admin"; + closingVm.UpdatePassword(" secret "); + Task pending = closingVm.LoginCommand.Execute(); + closingVm.Dispose(); + pending.GetAwaiter().GetResult(); + Check(!closingVm.CanEdit && handler.RequestCount == 3, "关闭取消请求"); + Console.WriteLine("PASS: LoginViewModel -> ILoginService -> AuthApi, retry, password contract and cancellation."); + return 0; + } + var app = new TestApp(client); app.InitializeComponent(); int exitCode = 0; app.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(async () => @@ -165,3 +205,12 @@ sealed class LoginHandler : HttpMessageHandler } + +sealed class TestApp(StoreApiClient client) : App +{ + protected override void RegisterTypes(IContainerRegistry containerRegistry) + { + base.RegisterTypes(containerRegistry); + containerRegistry.RegisterInstance(client); + } +} \ No newline at end of file