This commit is contained in:
chfei committed 2026-11-10 16:33:51 +08:00
1 parent 3135189ea3
commit a003d90b0e
21 files changed
+754 -145

No files matched your search

+42 -35
View File
@@ -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()`。
</Style>
```
## 顺序排列与间距布局
**页面排版需要横向、纵向顺序排列或简单自动换行时,优先使用 `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
<UserControl.Resources>
<conv:EnumDisplayConverter x:Key="EnumDisplayConverter"/>
<wpf:EnumDisplayConverter x:Key="EnumDisplayConverter" />
</UserControl.Resources>
<!-- 直接绑定枚举值 -->
<TextBlock Text="{Binding Status, Converter={StaticResource EnumDisplayConverter}}"/>
<ComboBox
ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type local:MyStatus},
Converter={StaticResource EnumDisplayConverter}}"
DisplayMemberPath="Display"
SelectedValuePath="Value"
wpf:EnumBindingExtension.SelectedEnum="{Binding SelectedStatus, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<!-- 只读显示单个枚举值 -->
<TextBlock Text="{Binding Status, Mode=OneWay, Converter={StaticResource EnumDisplayConverter}}" />
```
### ComboBox 绑定枚举(推荐:ViewModel 提供列表)
```csharp
// ViewModel
public IReadOnlyList<MyStatus> StatusList { get; } = Enum.GetValues(typeof(MyStatus)).Cast<MyStatus>().ToList();
public MyStatus SelectedStatus
{
get => GetProperty(() => SelectedStatus);
set => SetProperty(() => SelectedStatus, value);
}
```
```xml
<ComboBox ItemsSource="{Binding StatusList}"
SelectedItem="{Binding SelectedStatus}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding ., Converter={StaticResource EnumDisplayConverter}}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
```
### 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 示例
+2 -2
View File
@@ -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 { }
+10 -13
View File
@@ -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 的控件
<ResourceDictionary Source="/MAX.Common.WPF;component/Resources/Generic.xaml"/>
```
页面最小示例(绑定属性由页面的 DataContext 提供):
## 页面最小示例
绑定属性由页面的 DataContext 提供:
```xml
<StackPanel xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wpf="MAX.WPF">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wpf="MAX.WPF">
<StackPanel.Resources>
<wpf:Boolean2VisibilityConverter x:Key="ShowWhenTrue"/>
</StackPanel.Resources>
@@ -55,18 +59,11 @@ description: 在 Windows WPF 项目中接入和使用 MAX.Common.WPF 的控件
- 虚拟化依赖有限可视区域、滚动宿主和 ItemsPanel 配置。`VirtualizingItemsControl` 默认使用 VirtualizingStackPanel,需要换行时显式设置 VirtualizingWrapPanel。不能仅添加面板便承诺大数据性能。
- 通用命令在 `MAX.Common.Commands`,不是 WPF.Commands。MethodCommand<T> / AsyncMethodCommand<T> 的 T 需引用类型或可空值类型,状态变化手动 RaiseCanExecuteChanged;异步命令委托需自行处理业务异常。
## 枚举绑定示例
## 枚举绑定
已有 `xmlns:local` 指向业务枚举命名空间、`SelectedStatus` 是 `Status` 属性时:
枚举显示优先使用 MAX.Common.WPF 自带的 `EnumDisplayConverter`,并通过 `EnumBindingMarkupExtension.Converter` 显式传入;未设置转换器时只显示枚举成员名称。系统初始化时、视图加载前配置一次 `EnumDisplayConverter.EnumDisplayManager = new EnumDisplayManagerWPF(null);`;已有自定义管理器时复用现有配置。
```xml
<ComboBox ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type local:Status}}"
DisplayMemberPath="Display"
SelectedValuePath="Value"
wpf:EnumBindingExtension.SelectedEnum="{Binding SelectedStatus, Mode=TwoWay}"/>
```
允许空选项时使用 AllowNull 并让业务属性可空;自定义显示通过扩展的 Converter 提供,勿假设自动读取所有枚举描述标记。
初始化代码、Description 显示示例及选中值绑定见 [枚举绑定用法](references/api.md#markupextensionsenumbindingmarkupextensioncs)。
## 验证与交付
+191 -1
View File
@@ -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
<max:TitleContent Title="用户名">
<TextBox />
</max:TitleContent>
```
#### 2. 标题在左侧 + 固定标题宽度(表单场景)
```xml
<max:TitleContent Title="用户名"
Placement="Left"
TitleWidth="80"
TitleVerticalAlignment="Center"
LeftTitlePadding="0,0,8,0">
<TextBox />
</max:TitleContent>
```
#### 3. 标题使用复杂 UIElement
```xml
<max:TitleContent Placement="Top">
<max:TitleContent.Title>
<StackPanel Orientation="Horizontal">
<TextBlock Text="密码" />
<TextBlock Text="*" Foreground="Red" />
</StackPanel>
</max:TitleContent.Title>
<PasswordBox />
</max:TitleContent>
```
### 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
<max:SpacingStackPanel Orientation="Horizontal" HorizontalSpacing="8">
<Button Content="确定" />
<Button Content="取消" />
<Button Content="应用" />
</max:SpacingStackPanel>
```
##### 2. 垂直排列 + 行间距
```xml
<max:SpacingStackPanel Orientation="Vertical" VerticalSpacing="6">
<TextBlock Text="第一行" />
<TextBlock Text="第二行" />
<TextBlock Text="第三行" />
</max:SpacingStackPanel>
```
##### 3. 自动换行(标签云 / 按钮流)
```xml
<max:SpacingStackPanel Orientation="Horizontal"
ChildWrapping="True"
HorizontalSpacing="8"
VerticalSpacing="8">
<Button Content="标签1" />
<Button Content="标签2" />
<Button Content="标签3" />
<!-- ... -->
</max:SpacingStackPanel>
```
##### 4. 统一子项尺寸(等宽卡片网格)
```xml
<max:SpacingStackPanel Orientation="Horizontal"
ChildWrapping="True"
ItemWidth="120"
ItemHeight="80"
HorizontalSpacing="10"
VerticalSpacing="10">
<Border Background="#EEE" />
<Border Background="#EEE" />
<Border Background="#EEE" />
</max:SpacingStackPanel>
```
##### 5. 控制子项对齐
```xml
<max:SpacingStackPanel Orientation="Horizontal"
HorizontalSpacing="8"
ItemVerticalAlignment="Center">
<TextBlock Text="姓名:" />
<TextBox Width="160" />
</max:SpacingStackPanel>
```
### Controls/SpacingUniformGrid.cs
命名空间:`MAX.Common.WPF.Controls`
@@ -156,6 +292,60 @@ public override bool Equals(object obj)
public override int GetHashCode()
```
#### 显示名称与库自带转换器
MAX.Common.WPF 已提供 `EnumDisplayConverter`,枚举显示优先复用它,不必另写读取 Description 的转换器。`EnumBindingMarkupExtension` 不会自动使用这个转换器,必须通过 `Converter` 显式传入:
- `Value` 保存枚举值本身,例如 `EEE.Admin`。
- 未设置 `Converter` 时,`Display` 为枚举值的 `ToString()`,例如 `"Admin"`,不会由标记扩展直接读取 `Description`。
- 设置 `EnumDisplayConverter` 后,`Display` 由枚举显示管理器生成;界面仍绑定 `Display`,选中值绑定 `Value`。
#### 系统初始化
`EnumDisplayConverter` 提供全局静态属性 `public static EnumDisplayManagerBase EnumDisplayManager { get; set; }`。在系统初始化、相关视图加载之前设置一次:
```csharp
using MAX.Common.WPF.Converters;
using MAX.Common.WPF.Utilities;
EnumDisplayConverter.EnumDisplayManager = new EnumDisplayManagerWPF(null);
```
将赋值放进项目已有的启动初始化流程;若已配置自定义显示管理器,复用现有配置,不要重复覆盖。
#### Description 显示示例
```csharp
using System.ComponentModel;
public enum EEE
{
[Description("管理员")]
Admin,
[Description("用户")]
User
}
```
`xmlns:wpf="MAX.WPF"`,`xmlns:local` 指向枚举所在命名空间。在视图资源中注册库自带转换器,再将它传给标记扩展:
```xml
<UserControl.Resources>
<wpf:EnumDisplayConverter x:Key="EnumDisplayConverter" />
</UserControl.Resources>
<ComboBox
ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type local:EEE},
Converter={StaticResource EnumDisplayConverter}}"
DisplayMemberPath="Display"
SelectedValuePath="Value"
wpf:EnumBindingExtension.SelectedEnum="{Binding SelectedRole, Mode=TwoWay}" />
```
`SelectedRole` 是带属性变更通知的 `EEE` 属性。界面显示“管理员”“用户”,选中值仍为 `EEE.Admin` 或 `EEE.User`。只需要展示选项时,可以省略 `SelectedValuePath` 和 `SelectedEnum` 绑定。
允许空选项时设置 `AllowNull=True`,并让选中属性可空。`NullDisplay` 默认为空字符串;只有显式设置为 null 时,标记扩展才会尝试调用转换器生成空选项的显示内容。`DisplayNullLast` 控制空选项是否放在末尾。
### Extensions/EnumBindingExtension.cs
命名空间:`MAX.Common.WPF.Extensions`
@@ -5,8 +5,8 @@
| 需求 | 类型 / 命名空间 | 使用要点 |
| --- | --- | --- |
| 数值输入 | Controls.NumberBox | Value 为 double;Minimum、Maximum、Increment、DecimalPlaces、ValueFormat、ShowUpDownButton;模板资源在 Resources/Generic.xaml |
| 标题与条件内容 | Controls.TitleContent、BoolContent | TitleContent 有 Placement 等布局属性;BoolContent 用 Bool、TrueContent、FalseContent |
| 间距布局 | Controls.SpacingStackPanel、SpacingUniformGrid、AutoColumnsUniformGrid | 区分面板间距与子元素 Margin,按声明配置 |
| 标题与条件内容 | Controls.TitleContent、BoolContent | [TitleContent](api.md#controlstitlecontentcs) 提供标题位置、宽度、对齐和内边距,详见属性表与示例;BoolContent 用 Bool、TrueContent、FalseContent |
| 间距布局 | Controls.SpacingStackPanel、SpacingUniformGrid、AutoColumnsUniformGrid | [SpacingStackPanel](api.md#controlsspacingstackpanelcs) 支持水平/垂直间距、换行、统一子项尺寸与对齐,详见属性表与示例;区分面板间距与子元素 Margin |
| 图标与链接 | Controls.FontIcon、HyperlinkEx | FontIcon 继承 TextBlock,具体字体和字形必须与项目资源一致 |
| 按钮与弹出层 | Controls.MenuButton、ButtonPopup、CustomPopup、ClippingBorder | 按实际属性处理关闭、焦点和裁切,不猜测通用 Popup 行为 |
| 托盘图标 | Controls.NotifyIcon;Enums.NotifyIconInfoType | 涉及 Win32 资源与应用生命周期,检查实际清理 API |
@@ -14,7 +14,7 @@
| 标准控件附加行为 | Extensions.TextBoxExtension、ButtonExtension、ListBoxExtension、TreeViewExtension、SelectorExtension、ScrollViewerExtension、ProgressBarExtension、ToolTipExtension | 搜索对应 Get/Set 方法确认附加属性名称、默认值与事件机制 |
| 通用控件行为 | Extensions.ControlExtension(Context / DragDrop / Focus / Keyboard / Layout / Loaded / Mouse 分文件)、WindowExtension、GridLayoutHelper | 使用实际 API 名称,不把文件名当作附加属性名 |
| 上下文菜单和绑定代理 | Extensions.ContextMenuExtension、ContextMenuListener、BindingProxy / BooleanProxy、CommandProxy;Models.ContextMenuModel;ViewModels.ContextMenuViewModel | Popup/ContextMenu 不在普通可视树,核对 DataContext 来源 |
| 枚举选择与显示 | MarkupExtensions.EnumBindingMarkupExtension;Extensions.EnumBindingExtension;Converters.EnumDisplayConverter;Utilities.EnumDisplayManagerWPF | ItemsSource 返回包装对象;旧 EnumBindingComboBox 已弃用 |
| 枚举选择与显示 | MarkupExtensions.EnumBindingMarkupExtension;Extensions.EnumBindingExtension;Converters.EnumDisplayConverter;Utilities.EnumDisplayManagerWPF | [枚举绑定用法](api.md#markupextensionsenumbindingmarkupextensioncs):显式传入库自带 EnumDisplayConverter,启动时初始化 EnumDisplayManager;ItemsSource 返回 Display/Value 包装对象;旧 EnumBindingComboBox 已弃用 |
| 布尔、空值与可见性 | Converters.Boolean2VisibilityConverter、Boolean2VisibilityReConverter、Object2VisibilityConverter、String2VisibilityConverter、IListToVisibilityConverter、BooleanArr2VisibilityConverter 等 | 各 Converter 对 null、空串、集合的定义不同,核对实现;多值转换器需 MultiBinding |
| 格式、颜色、尺寸转换 | Converters.FileSizeDisplayConverter、DateTimeFormatConverter、FormatConverter、StringJoinConverter、ColorToBrushConverter、ContrastBrushConverter、ThicknessSplitConverter、CornerRadiusSplitConverter 等 | 按 IValueConverter / IMultiValueConverter 及 ConvertBack 支持情况选择绑定模式 |
| 视图生命周期 | ViewModels.ViewModelBase;Extensions.ViewModelExtension | LoadedOnce / Loaded / Unloaded;关联视图并非 IoC 自动创建 ViewModel |
+15 -3
View File
@@ -26,12 +26,24 @@ button,input,select{border-radius:2px;font-size:12px}button{padding:3px 10px;hei
th:first-child,td:first-child{width:30px;min-width:30px;padding:0 8px;position:sticky;left:0;background:white;z-index:1}th:first-child{background:#f0f2f4;z-index:3}.row-number{color:#9299a1;text-align:right;font-size:11px;min-width:34px}thead th{z-index:2}th:last-child{z-index:3;background:#f0f2f4}td:last-child,th:last-child{box-shadow:-3px 0 4px #0000000a;border-left:1px solid #dce0e4}
tbody tr:nth-child(even) td{background:#fafbfc}tbody tr:hover td{background:#f0f2f4}tbody tr.selected td{background:#f3eaf1}.entity{gap:0}.entity-mark,.entity small,.compact .entity-mark{display:none}.entity-name{margin:0;font-size:12px;font-weight:500}.pill{font-size:11px;padding:0 5px;border-radius:2px;line-height:19px}.tag{background:none;padding:0;font-size:12px;color:#60666e}.date{font-size:12px}.subline{display:inline;font-size:10px;margin-left:6px}.mono{color:#69727d;font-size:11px}.muted{color:#7c838c}.row-action{padding:0 5px!important;height:23px!important}.pagination{height:35px;min-height:35px;flex-shrink:0;padding:3px 9px;background:#f7f8f9;font-size:11px}.pagination button,.pagination select{height:24px}.pagination-controls{gap:6px}.current-page{padding:2px 8px}.statusbar{height:23px;min-height:23px;align-items:center;padding:0 5px;font-size:10px}
.drawer{margin:auto;width:620px;max-width:90vw;height:auto;max-height:90vh;border-radius:3px}.dialog-head{padding:11px 15px;background:#f5f6f7}.dialog-head h2{font-size:14px}.dialog-head p{font-size:10px;margin-top:2px}.dialog-body{padding:15px;gap:13px}.drawer .dialog-body{display:grid;grid-template-columns:1fr 1fr}.drawer .dialog-body>.help{grid-column:1/-1}.dialog-body input:not([type=checkbox]),.dialog-body select{height:29px}.dialog-body .field{gap:5px}.dialog-footer{padding:9px 15px}.permissions{grid-template-columns:1fr;gap:6px}.columns-dialog{border-radius:3px}.help{border-radius:2px;font-size:11px}
/* Desktop window frame and navigation hierarchy. */
.app{grid-template-columns:180px minmax(0,1fr);grid-template-rows:32px minmax(0,1fr);height:100dvh;min-height:594px;border:1px solid #c9c4cd}
.ui-icon{display:block;width:16px;height:16px;flex-shrink:0;fill:none;stroke:currentColor;stroke-width:1.65;stroke-linecap:round;stroke-linejoin:round}
.window-titlebar{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;background:var(--brand);border-bottom:1px solid var(--brand);user-select:none}.window-caption{display:flex;align-items:center;gap:8px;padding-left:11px;color:#fff;font-size:11px}.window-caption .ui-icon{color:#fff}.window-actions{display:flex;height:31px}.window-actions button{display:grid;place-items:center;width:46px;height:31px;padding:0;border:0;border-radius:0;background:transparent;color:#fff}.window-actions .ui-icon{width:12px;height:12px;stroke-width:1}.window-actions button:hover{background:#ffffff25}.window-actions .window-close:hover{background:#c42b1c;color:#fff}
.sidebar{grid-column:1;grid-row:2;min-height:0;overflow:auto}.shell{grid-column:2;grid-row:2;min-height:0}
.brand{justify-content:center;padding:0;background:#f7f8f9;border-bottom:1px solid #e2e4e8;color:var(--brand)}.brand>.ui-icon{width:32px;height:32px;stroke-width:1.5}
.section-label{display:flex;align-items:center;gap:8px;margin:13px 8px 4px;padding:7px 8px;background:transparent;color:#65516b;font-size:12px;font-weight:650;border-radius:3px;letter-spacing:0}.section-label>.ui-icon{width:17px;height:17px}
.sidebar nav{margin:0 8px 0 16px;border-left:1px solid #ddd6e0;padding-left:5px}.sidebar .nav{height:34px;padding:0 9px;gap:8px;border-radius:3px;font-size:12px;color:#727785}.sidebar .nav.active{padding-left:9px;background:transparent;color:var(--brand-dark);font-weight:650}.sidebar .nav .ui-icon{width:15px;height:15px}.sidebar .nav:hover:not(:disabled){background:transparent;color:var(--brand-dark)}.sidebar .nav.active:after{display:block;content:"";height:14px;width:2px;border-radius:1px;background:var(--brand);margin-left:auto}
.title-account{display:flex;align-items:center;gap:12px;margin-left:auto;padding:0 18px;color:#fff;font-size:10px}
.title-account b{font-weight:600;color:#fff}
.account-divider{height:11px;border-left:1px solid #ffffff40}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar"><div class="brand"><b>29</b><div><strong>供应链</strong><small>COMMERCE WORKSPACE</small></div></div><div class="section-label">店铺工作台</div><nav aria-label="店铺工作台"><button class="nav active" data-page="stores"><span class="nav-index">01</span>店铺管理</button><button class="nav" data-page="accounts"><span class="nav-index">02</span>子账号管理</button></nav><div class="section-label">超级管理</div><nav aria-label="超级管理"><button class="nav" data-page="superstores"><span class="nav-index">03</span>超级店铺管理</button><button class="nav" data-page="users"><span class="nav-index">04</span>超级用户管理</button><button class="nav" data-page="oem"><span class="nav-index">05</span>OEM 贴牌管理</button></nav><div class="sidebar-bottom"><span class="demo-badge">桌面工作台 · 演示</span><p>专注每一家店铺的日常运营<br>本地交互演示 · 数据均为模拟</p><button class="text" id="designNotes">界面设计说明</button></div></aside>
<div class="shell"><header class="topbar"><div class="breadcrumb">管理工作空间 <span>/</span><span id="crumb">店铺管理</span></div><div class="profile"><span>账户积分 <b>9,759</b></span><span class="avatar">管</span><span>管理员</span></div></header>
<div class="app"><div class="window-titlebar"><div class="window-caption"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2 9 5v10l-9 5-9-5V7zM3 7l9 5 9-5M12 12v10M7.5 4.5l9 5V14"/></svg><span>29 供应链 — 运营管理工作台</span></div><div class="title-account"><span>积分 <b>9,759</b></span><span class="account-divider"></span><span>管理员</span></div><div class="window-actions" aria-label="窗口按钮外观演示"><button type="button" class="" aria-label="最小化(外观演示)" title="最小化 · 仅外观演示"><svg class="ui-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6h10"/></svg></button><button type="button" class="" aria-label="最大化(外观演示)" title="最大化 · 仅外观演示"><svg class="ui-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="M1.5 1.5h9v9h-9z"/></svg></button><button type="button" class="window-close" aria-label="关闭(外观演示)" title="关闭 · 仅外观演示"><svg class="ui-icon" viewBox="0 0 12 12" aria-hidden="true"><path d="m1.5 1.5 9 9m0-9-9 9"/></svg></button></div></div>
<aside class="sidebar"><div class="brand" aria-label="29 供应链"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2 9 5v10l-9 5-9-5V7zM3 7l9 5 9-5M12 12v10M7.5 4.5l9 5V14"/></svg></div><div class="section-label"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 10v11h18V10M2 10l2-7h16l2 7zM9 21v-7h6v7M7 3v7M17 3v7"/></svg><span>店铺工作台</span></div><nav aria-label="店铺工作台"><button class="nav active" data-page="stores"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 10v11h18V10M2 10l2-7h16l2 7zM9 21v-7h6v7M7 3v7M17 3v7"/></svg>店铺管理</button><button class="nav" data-page="accounts"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3M9 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8M18 3a4 4 0 0 1 0 8M22 21v-3a4 4 0 0 0-3-4"/></svg>子账号管理</button></nav><div class="section-label"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l9 4v6c0 5-9 10-9 10S3 17 3 12V6zM8 12l3 3 5-6"/></svg><span>超级管理</span></div><nav aria-label="超级管理"><button class="nav" data-page="superstores"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 10v11h18V10M2 10l2-7h16l2 7zM9 21v-7h6v7M7 3v7M17 3v7"/></svg>超级店铺管理</button><button class="nav" data-page="users"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3M9 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8M18 3a4 4 0 0 1 0 8M22 21v-3a4 4 0 0 0-3-4"/></svg>超级用户管理</button><button class="nav" data-page="oem"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 3h10l7 7-11 11-7-7V4zM8 7h.01"/></svg>OEM 贴牌管理</button></nav><div class="sidebar-bottom"><span class="demo-badge">桌面工作台 · 演示</span><p>专注每一家店铺的日常运营<br>本地交互演示 · 数据均为模拟</p><button class="text" id="designNotes">界面设计说明</button></div></aside>
<div class="shell"><span id="crumb" hidden></span>
<main class="content"><section class="page-heading"><div><div class="eyebrow" id="eyebrow">STORE MANAGEMENT</div><h1 id="pageTitle">店铺管理</h1><p id="pageDescription"></p></div><div class="heading-actions"><button id="refresh">刷新列表</button><button class="primary" id="add">添加店铺</button></div></section>
<section class="workspace" aria-label="管理列表"><div class="tabs" id="tabs"></div><form class="filters" id="filterForm"><div class="field search-field"><label for="keyword">关键词</label><input id="keyword" maxlength="100" placeholder="搜索店铺名称 / 店铺 ID"></div><div class="field"><label for="groupFilter">所属分组</label><select id="groupFilter"></select></div><div class="field"><label for="statusFilter">状态</label><select id="statusFilter"><option value="">全部状态</option><option>正常</option><option>即将到期</option><option>已过期</option></select></div><button class="primary" type="submit">查询</button><button type="button" id="reset">重置</button></form>
+58
View File
@@ -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(/<script>([\s\S]*?)<\/script>/)[1];
const elements = new Map();
function element(id) {
if (!elements.has(id)) elements.set(id, { value: id === 'pageSize' ? '50' : '', innerHTML: '', textContent: '', classList: { toggle() { return true; } }, setAttribute() {}, showModal() {}, close() {} });
return elements.get(id);
}
const context = vm.createContext({ document: { getElementById: element, querySelectorAll: () => [] }, console: { assert: assert.ok }, setTimeout: () => 1, clearTimeout() {}, FormData, assert });
vm.runInContext(script, context);
vm.runInContext(`
assert.equal(visibleRows().length, 50);
page=2; assert.equal(visibleRows().length, 22);
query='乐乐'; assert.equal(filtered().length, 3);
query=''; group='服饰鞋包'; assert.equal(filtered().length, 24);
group=''; tab='已过期'; assert.ok(filtered().every(r=>r.status==='已过期'));
for (const key of Object.keys(configs)) {
switchPage(key);
assert.ok($('tbody').innerHTML.includes('data-edit'));
openEditor(data[key][0].id);
assert.ok($('editorFields').innerHTML.includes('name="name"'));
hiddenColumns.add('group'); renderTable();
assert.ok(!$('thead').innerHTML.includes('所属分组'));
}
switchPage('stores');
assert.equal(configs.stores.columns.length, 11);
assert.equal(configs.users.columns.length, 13);
assert.ok($('tbody').innerHTML.includes('data-store-action'));
selected.add(data.stores[3].id); $('login').onclick();
assert.equal(data.stores[3].login, '登录成功');
$('batch').onclick();
assert.equal(data.stores[3].status, '正常');
assert.equal(selected.size, 0);
query='no-matching-record'; renderTable();
assert.ok($('tbody').innerHTML.includes('没有匹配的记录'));
assert.equal($('next').disabled, true);
assert.equal(escapeHtml('<img onerror="bad">'), '&lt;img onerror=&quot;bad&quot;&gt;');
`, context);
console.log('PASS: script syntax, five page renders, pagination, filters, editors, columns, batch action, empty state and HTML escaping.');
// Verify contextual navigation against the actual HTML attributes.
const markup = html.split("<script>")[0];
const ids = [...markup.matchAll(/\bid="([^"]+)"/g)].map(m => m[1]);
assert.equal(ids.length, new Set(ids).size);
const modules = [...markup.matchAll(/data-module="([^"]+)" data-start="([^"]+)"/g)].map(m => ({ dataset: { module: m[1], start: m[2] }, classList: { toggle() {} }, setAttribute(name, value) { this[name] = value; } }));
const pages = [...markup.matchAll(/data-area="([^"]+)" data-page="([^"]+)"/g)].map(m => ({ dataset: { area: m[1], page: m[2] }, classList: { toggle() {} }, setAttribute() {} }));
context.document.querySelectorAll = selector => selector === "[data-module]" ? modules : pages;
assert.equal(modules.length, 3); assert.equal(pages.length, 5);
for (const page of pages) {
vm.runInContext("switchPage(" + JSON.stringify(page.dataset.page) + ")", context);
assert.equal(page.hidden, false);
assert.ok(pages.every(p => p.hidden === (p.dataset.area !== page.dataset.area)));
assert.equal(modules.filter(m => m["aria-pressed"]).length, 1);
assert.equal(modules.find(m => m["aria-pressed"]).dataset.module, page.dataset.area);
}
console.log("PASS: three icon modules, five contextual pages, active and hidden navigation states.");
+253
View File
@@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>29 供应链 · 桌面工作台 Demo 2</title>
<style>
:root{--brand:#98638f;--brand-dark:#784b70;--tint:#f3ebf2;--ink:#353a44;--muted:#9299a4;--line:#e0e4e9;--canvas:#f4f5f7;--green:#3b876e;--red:#bc626b;--amber:#aa7b35}
*{box-sizing:border-box}
body{margin:0;min-width:1040px;font:12px/1.5 "Segoe UI","Microsoft YaHei",sans-serif;color:var(--ink);background:var(--canvas)}
button,input,select{font:inherit;color:inherit}
button{cursor:pointer;white-space:nowrap;height:28px;padding:0 10px;background:white;border:1px solid #d8dde4;border-radius:4px}
button:hover:not(:disabled){background:#f2f4f7;border-color:#bcc4ce}
button:disabled{color:#abb2bc;cursor:default}
button:focus-visible,input:focus-visible,select:focus-visible{outline:2px solid #98638f;outline-offset:2px}
button.primary{background:var(--brand);color:white;border-color:transparent}
button.primary:hover:not(:disabled){background:var(--brand-dark);border-color:transparent}
input,select{height:28px;padding:0 8px;border:1px solid #d8dde4;border-radius:4px;background:white;min-width:0}
input[type=checkbox]{width:13px;height:13px;margin:0;vertical-align:middle;accent-color:var(--brand);cursor:pointer}
[hidden]{display:none!important}
svg{display:block;width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}
.sprite{position:absolute;width:0;height:0;overflow:hidden}
.app{height:100dvh;min-height:580px;display:grid;grid-template-rows:32px 60px minmax(0,1fr)}
.window-titlebar{display:flex;align-items:center;justify-content:space-between;background:#f5f3f6;border-bottom:1px solid #e8e4ea;user-select:none}
.window-caption{display:flex;align-items:center;gap:8px;padding-left:12px;font-size:11px;color:#77717c}
.window-caption svg{width:16px;height:16px;color:var(--brand)}
.window-actions{display:flex;height:32px}
.window-actions button{display:grid;place-items:center;width:46px;height:32px;padding:0;border:0;border-radius:0;background:transparent;color:#55525b}
.window-actions button svg{width:12px;height:12px;stroke-width:1}
.window-actions button:hover{background:#e7e4e9;border:0}
.window-actions .window-close:hover{background:#c42b1c;color:#fff}
.app-header{display:flex;align-items:stretch;background:#fff;border-bottom:1px solid #e5e3e9}
.brand{width:180px;min-width:180px;display:flex;align-items:center;gap:10px;padding:0 18px;background:#faf8fb}
.brand-mark{height:34px;width:34px;display:grid;place-items:center;color:var(--brand);flex-shrink:0}
.brand-mark svg{width:32px;height:32px;stroke-width:1.35}
.brand strong{display:block;font-size:16px;font-weight:650;letter-spacing:.5px;color:#60445c}
.brand small{display:none}
.modulebar{display:flex;gap:8px;align-items:center;padding:0 16px}
.module{display:flex;align-items:center;gap:9px;height:40px;padding:0 16px;border:1px solid transparent;border-radius:6px;background:transparent;color:#788191;font-size:12px}
.module svg{width:21px;height:21px}
.module:hover:not(:disabled){background:#f4f3f7;border-color:transparent}
.module.active{color:var(--brand-dark);background:#eee5ed;font-weight:600}
.account{display:flex;align-items:center;gap:15px;margin-left:auto;padding:0 18px;font-size:11px;color:#8a929e}
.account b{font-weight:600;color:#746071}
.account .avatar{display:grid;place-items:center;width:27px;height:27px;background:#eee8ef;color:#8e6e89;border-radius:50%;font-size:11px}
.account button{background:transparent;border:0;color:#8a929e;padding:0 2px;font-size:11px}
.desktop-body{display:grid;grid-template-columns:180px minmax(0,1fr);min-height:0}
.function-panel{display:flex;flex-direction:column;background:#faf8fb;border-right:1px solid #e7e3e9;padding:16px 10px 12px}
.panel-heading{display:flex;justify-content:space-between;align-items:center;padding:0 10px 16px;color:#929aa7;font-size:11px}
.panel-heading b{font-weight:500}
.panel-heading span{font-size:10px;color:#a49aa9;letter-spacing:1px}
.nav{display:flex;align-items:center;gap:10px;text-align:left;width:100%;height:35px;margin-bottom:5px;border:0;background:transparent;border-radius:5px;padding:0 11px;color:#6c7685}
.nav svg{width:16px;height:16px}
.nav.active{background:#eee5ed;color:var(--brand-dark);font-weight:600}
.nav.active:after{content:"";width:5px;height:5px;border-right:1px solid currentColor;border-top:1px solid currentColor;transform:rotate(45deg);margin-left:auto;opacity:.6}
.panel-bottom{margin-top:auto;padding:12px 10px 0;border-top:1px solid #e6e9ee;color:#91a095;font-size:10px}
.panel-bottom small{display:block;color:#a8afb9;font-size:9px;margin-top:4px}
.shell{display:flex;min-width:0;min-height:0}
.content{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;padding:0 12px}
.page-heading{height:44px;min-height:44px;display:flex;align-items:center;gap:12px;padding:0 1px}
h1{font-size:14px;font-weight:600;margin:0}
.page-heading p{display:none}
.workspace{display:flex;flex-direction:column;flex:1;min-height:0;background:#fff;border:1px solid #dce1e7;border-radius:5px;overflow:hidden}
.toolbar{display:flex;align-items:center;gap:9px;min-height:43px;padding:7px 10px;border-bottom:1px solid #e6e9ee}
.command-group{display:flex;gap:5px;align-items:center;padding-right:9px;border-right:1px solid #e6e9ee}
.command-group:last-child{border:0;margin-left:auto;padding:0}
.toolbar button{display:flex;align-items:center;gap:6px;font-size:11px;border-color:transparent;background:transparent;padding:0 8px}
.toolbar button.primary{background:var(--brand);color:white;padding:0 10px}
.toolbar button:before{content:"";height:15px;width:15px;background:currentColor;mask:var(--icon) center/contain no-repeat}
.filters{display:flex;align-items:center;gap:10px;min-height:44px;margin:0;padding:7px 10px;background:#fcfcfd;border-bottom:1px solid #e8ebef;flex-wrap:wrap}
.field{display:flex;flex-direction:column;gap:5px}
.filters .field{flex-direction:row;align-items:center;gap:7px}
.field label{font-size:11px;color:#89929f;white-space:nowrap}
.search-field input{width:230px}
.filters select{width:105px}
.filters button{font-size:11px}
.list-header{min-height:35px;height:35px;display:flex;align-items:center;justify-content:space-between;padding:0 11px;gap:8px}
.tabs{display:flex;align-items:stretch;height:35px;gap:18px}
.tab{height:35px;border:0;padding:0;background:transparent;border-radius:0;color:#9199a5;font-size:11px;position:relative}
.tab.active{color:var(--brand-dark);font-weight:600}
.tab.active:after{content:"";position:absolute;bottom:0;left:0;right:0;background:var(--brand);height:2px}
.tab small{font-size:10px;margin-left:6px;background:#f2f4f6;border-radius:3px;padding:0 4px}
.selection-info{display:flex;gap:10px;align-items:center;font-size:10px;white-space:nowrap}
.selection-info>span+span{padding-left:10px;border-left:1px solid #e5e8ed}
.selection{color:var(--brand-dark)}
.muted{color:var(--muted)}
.table-scroll{flex:1;min-height:100px;overflow:auto;border-top:1px solid #dce1e7}
table{border-collapse:separate;border-spacing:0;width:max-content;min-width:100%;font-size:12px;white-space:nowrap}
th{text-align:left;position:sticky;top:0;z-index:2;background:#f1f3f6;font-weight:500;color:#66717f;height:30px;border-right:1px solid #e0e5eb;border-bottom:1px solid #d9dfe6;padding:0 10px}
td{height:36px;padding:2px 10px;border-bottom:1px solid #edf0f3;border-right:1px solid #edf0f3;background:#fff}
.compact td{height:29px;padding-top:1px;padding-bottom:1px}
tbody tr:nth-child(even) td{background:#fcfcfd}
tbody tr:hover td{background:#f6f3f7}
tbody tr.selected td{background:#f0e6ef}
th:first-child,td:first-child{width:31px;min-width:31px;text-align:center;padding:0 8px;position:sticky;left:0;z-index:1}
th:first-child{z-index:3}
th:last-child,td:last-child{position:sticky;right:0;border-left:1px solid #dfe4ea;box-shadow:-3px 0 5px #00000004}
th:last-child{z-index:3}
.row-number{text-align:right;color:#9ba4b0;font-size:11px}
.entity-mark,.entity small{display:none}
.entity-name{font-weight:400}
.mono{font:11px Consolas,monospace;color:#838d9c}
.date{font-size:11px;color:#778291;font-variant-numeric:tabular-nums}
.subline{display:inline;font-size:10px;margin-left:5px}
.pill{font-size:10px;color:var(--green);background:#edf6f1;display:inline-flex;gap:5px;align-items:center;padding:1px 5px;border-radius:3px}
.pill:before{content:"";width:4px;height:4px;background:currentColor;border-radius:50%}
.pill.warn{color:var(--amber);background:#faf3e9}
.pill.error{color:var(--red);background:#fbefef}
.tag{color:#7e8794;font-size:11px}
button.text{background:transparent;border:0;color:#7c6a7b;padding:0 5px;height:24px;font-size:11px}
.sort{border:0;background:transparent;padding:0;height:auto;color:inherit;font:inherit}
.empty{text-align:center;height:150px!important;color:var(--muted)}
.pagination{height:36px;min-height:36px;display:flex;justify-content:space-between;align-items:center;padding:0 10px;border-top:1px solid #dfe4ea;background:#fcfcfd;font-size:10px;color:#959daa}
.pagination-controls{display:flex;align-items:center;gap:6px}
.pagination button,.pagination select{height:24px;font-size:11px}
.current-page{padding:2px 7px;background:var(--tint);color:var(--brand-dark);border-radius:3px}
.statusbar{height:23px;min-height:23px;display:flex;align-items:center;justify-content:space-between;font-size:9px;color:#a4abb5;padding:0 1px}
dialog{border:1px solid #dce1e7;border-radius:7px;padding:0;color:var(--ink);box-shadow:0 18px 70px #30233330;max-width:90vw;max-height:90vh}
dialog::backdrop{background:#20232c45}
.drawer{width:620px}
.dialog-head{padding:12px 16px;background:#fafbfc;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}
.dialog-head h2{font-size:14px;margin:0}
.dialog-head p{font-size:10px;color:var(--muted);margin:3px 0 0}
.dialog-body{padding:16px;display:flex;flex-direction:column;gap:13px}
.drawer .dialog-body{display:grid;grid-template-columns:1fr 1fr}
.dialog-body input:not([type=checkbox]),.dialog-body select{width:100%;height:30px}
.dialog-footer{padding:10px 16px;display:flex;justify-content:flex-end;gap:8px;border-top:1px solid var(--line)}
.help{grid-column:1/-1;background:var(--canvas);color:var(--muted);padding:10px;font-size:11px}
.permissions{display:grid;gap:7px;font-size:11px}
.permissions label,.columns-dialog label{display:flex;align-items:center;gap:8px}
.columns-dialog{width:330px}
.notice{padding:10px;background:var(--tint);color:var(--brand-dark);border-radius:4px}
.toast{position:fixed;z-index:10;bottom:35px;left:50%;transform:translateX(-50%);padding:10px 18px;background:#4a3c49;color:white;border-radius:5px;box-shadow:0 5px 20px #0002}
@media(max-width:1180px){.account{gap:10px;padding:0 12px}.account .points{display:none}.module{padding:0 13px}.page-heading p{display:none}.content{padding:0 8px}.toolbar{gap:5px}.command-group{gap:3px;padding-right:5px}.toolbar button{padding:0 6px}.search-field input{width:190px}.filters{gap:8px}.selection-info #resultCount{display:none}}
#add{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M12%205v14M5%2012h14%22%2F%3E%3C%2Fsvg%3E")}
#refresh{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M20%207v5h-5M4%2017v-5h5M5%207a8%208%200%200%201%2014-1l1%206M4%2012l1%206a8%208%200%200%200%2014-1%22%2F%3E%3C%2Fsvg%3E")}
#batch{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M8%203h13v14H8zM3%208v13h13M11%207h7M11%2011h7%22%2F%3E%3C%2Fsvg%3E")}
#detect{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M4%2012l5%205L20%206%22%2F%3E%3C%2Fsvg%3E")}
#login{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M14%203h7v18h-7M3%2012h13M11%207l5%205-5%205%22%2F%3E%3C%2Fsvg%3E")}
#density{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M3%205h18M3%2012h18M3%2019h18%22%2F%3E%3C%2Fsvg%3E")}
#columnSettings{--icon:url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22black%22%20stroke-width%3D%221.7%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22M3%204h18v16H3zM9%204v16M15%204v16%22%2F%3E%3C%2Fsvg%3E")}
</style>
</head>
<body>
<svg class="sprite" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><defs>
<symbol id="i-box" viewBox="0 0 24 24"><path d="m12 2 9 5v10l-9 5-9-5V7zM3 7l9 5 9-5M12 12v10M7.5 4.5l9 5V14"/></symbol>
<symbol id="i-shop" viewBox="0 0 24 24"><path d="M3 10v11h18V10M2 10l2-7h16l2 7zM9 21v-7h6v7M7 3v7M17 3v7"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 2l9 4v6c0 5-9 10-9 10S3 17 3 12V6zM8 12l3 3 5-6"/></symbol>
<symbol id="i-brand" viewBox="0 0 24 24"><path d="M4 3h10l7 7-11 11-7-7V4zM8 7h.01"/></symbol>
<symbol id="i-people" viewBox="0 0 24 24"><path d="M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3M9 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8M18 3a4 4 0 0 1 0 8M22 21v-3a4 4 0 0 0-3-4"/></symbol>
</defs></svg>
<div class="app">
<div class="window-titlebar">
<div class="window-caption"><svg aria-hidden="true"><use href="#i-box"/></svg><span>29 供应链</span></div>
<div class="window-actions" aria-label="窗口按钮外观演示">
<button type="button" aria-label="最小化(外观演示)" title="最小化 · 仅外观演示"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1 6h10"/></svg></button>
<button type="button" aria-label="最大化(外观演示)" title="最大化 · 仅外观演示"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="M1.5 1.5h9v9h-9z"/></svg></button>
<button type="button" class="window-close" aria-label="关闭(外观演示)" title="关闭 · 仅外观演示"><svg viewBox="0 0 12 12" aria-hidden="true"><path d="m1.5 1.5 9 9m0-9-9 9"/></svg></button>
</div>
</div>
<header class="app-header">
<div class="brand"><span class="brand-mark"><svg aria-hidden="true"><use href="#i-box"/></svg></span><div><strong>29 供应链</strong></div></div>
<nav class="modulebar" aria-label="业务模块">
<button class="module" data-module="operation" data-start="stores"><svg aria-hidden="true"><use href="#i-shop"/></svg>店铺运营</button>
<button class="module" data-module="admin" data-start="users"><svg aria-hidden="true"><use href="#i-shield"/></svg>超级管理</button>
<button class="module" data-module="brand" data-start="oem"><svg aria-hidden="true"><use href="#i-brand"/></svg>品牌管理</button>
</nav>
<div class="account"><span class="points">账户积分 <b>9,759</b></span><button id="designNotes">布局说明</button><span class="avatar" aria-hidden="true">管</span><span>管理员</span></div>
</header>
<div class="desktop-body">
<aside class="function-panel" aria-label="模块内功能">
<div class="panel-heading"><span>工作区</span><b id="moduleTitle" hidden>店铺运营</b></div>
<button class="nav" data-area="operation" data-page="stores"><svg aria-hidden="true"><use href="#i-shop"/></svg><span>店铺管理</span></button>
<button class="nav" data-area="operation" data-page="accounts"><svg aria-hidden="true"><use href="#i-people"/></svg><span>子账号管理</span></button>
<button class="nav" data-area="admin" data-page="superstores"><svg aria-hidden="true"><use href="#i-shop"/></svg><span>超级店铺</span></button>
<button class="nav" data-area="admin" data-page="users"><svg aria-hidden="true"><use href="#i-people"/></svg><span>超级用户</span></button>
<button class="nav" data-area="brand" data-page="oem"><svg aria-hidden="true"><use href="#i-brand"/></svg><span>OEM 贴牌</span></button>
<div class="panel-bottom">● 本地演示环境<small>数据仅在本次浏览中生效</small></div>
</aside>
<div class="shell"><main class="content">
<section class="page-heading"><h1 id="pageTitle">店铺管理</h1><p id="pageDescription"></p><span id="crumb" hidden></span><span id="eyebrow" hidden></span></section>
<section class="workspace" aria-label="管理列表">
<div class="toolbar" aria-label="当前页面操作">
<div class="command-group" role="group" aria-label="记录操作"><button class="primary" id="add">添加店铺</button><button id="refresh">刷新列表</button></div>
<div class="command-group" role="group" aria-label="选中记录操作"><button id="batch" disabled>批量续期</button><button id="detect" disabled>检测选中</button><button id="login" disabled>登录选中</button></div>
<div class="command-group" role="group" aria-label="表格视图"><button id="density" aria-pressed="true">舒适视图</button><button id="columnSettings">列设置</button></div>
</div>
<form class="filters" id="filterForm">
<div class="field search-field"><label for="keyword">关键词</label><input id="keyword" maxlength="100" placeholder="搜索店铺名称 / 店铺 ID"></div>
<div class="field"><label for="groupFilter">分组</label><select id="groupFilter"></select></div>
<div class="field"><label for="statusFilter">状态</label><select id="statusFilter"><option value="">全部状态</option></select></div>
<button class="primary" type="submit">查询</button><button type="button" id="reset">重置</button>
</form>
<div class="list-header"><div class="tabs" id="tabs"></div><div class="selection-info"><span id="selection" class="muted">选择记录后可批量操作</span><span id="resultCount" class="muted"></span></div></div>
<div class="table-scroll"><table id="table" class="compact"><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<footer class="pagination"><span id="range"></span><div class="pagination-controls"><label for="pageSize">每页</label><select id="pageSize"><option>20</option><option selected>50</option><option>100</option></select><button id="prev" aria-label="上一页">上一页</button><span class="current-page" id="pageNumber">1</span><button id="next" aria-label="下一页">下一页</button></div></footer>
</section>
<footer class="statusbar"><span id="loadStatus">数据已就绪 · 本地演示</span><span>Demo 2 · 桌面工作台</span></footer>
</main></div></div></div>
<dialog class="drawer" id="editor"><form id="editForm"><header class="dialog-head"><div><h2 id="editorTitle"></h2><p>修改仅在本次演示中生效</p></div><button type="button" class="text" data-close="editor">关闭</button></header><div class="dialog-body" id="editorFields"></div><footer class="dialog-footer"><button type="button" data-close="editor">取消</button><button class="primary" type="submit">保存</button></footer></form></dialog>
<dialog class="columns-dialog" id="columns"><header class="dialog-head"><h2>显示的列</h2><button class="text" data-close="columns">完成</button></header><div class="dialog-body" id="columnOptions"></div></dialog>
<dialog id="details" style="width:520px;max-width:95vw"><header class="dialog-head"><h2 id="detailTitle"></h2><button class="text" data-close="details">关闭</button></header><div class="dialog-body" id="detailBody"></div></dialog><div class="toast" role="status" id="toast" hidden></div>
<script>
'use strict';
const $=id=>document.getElementById(id);
const escapeHtml=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const names=['乐乐生活馆','清麦日用百货','栖木家居旗舰店','佐薇服饰小铺','青禾优选','云朵母婴生活','拾光好物集合店','橙意食品专营店','木子家居日用','浅湾服饰店','山野户外生活','简物生活旗舰店','暖晨家纺店','知味食品小铺','白露日用百货','云间服饰集合店','小满优选','春序家居店','鹿鸣母婴店','青山好物','星野百货','暮色生活馆','沐光服饰','初禾日用品'];
const stores=Array.from({length:72},(_,i)=>({name:names[i%names.length]+(i>=24?" · "+(Math.floor(i/24)+1)+"店":""),i})).map(({name,i})=>({id:1001+i,name,code:'wx'+(316129135100+i*74031).toString(16)+'a'+i,group:['精选店铺','服饰鞋包','生活百货'][i%3],account:['运营一组','运营二组','未分配'][i%3],owner:'demo_owner_'+(i%4+1),phone:'138****'+String(1200+i),status:i%7===3?'已过期':i%5===2?'即将到期':'正常',expires:i%7===3?'2026-10-20':i%5===2?'2026-11-02':'2027-05-09',note:i%3===0?'重点运营店铺':'',permissions:['店铺列表','订单管理'],created:'2026-08-14 15:16',login:i%7===3?'未登录':'登录成功',api:i%4===0?'未购买':'已开通',permanent:i%9===0?'是':'否',secondary:i%6===0?'已启用':'未启用'}));
const data={stores,superstores:stores.map(r=>({...r,id:r.id+3000})),accounts:['林小夏','陈晓','周宁','许安','李可','宋悦'].map((name,i)=>({id:2001+i,name,code:'operator_'+(i+1),phone:'139****'+(2300+i),group:['运营','客服','仓储'][i%3],status:i===4?'已停用':'正常',account:'主账号 · 演示企业',note:'',permissions:['店铺列表','订单管理']})),users:Array.from({length:18},(_,i)=>({id:3001+i,name:['青禾供应链','云飞商贸','开兴科技','花下海','众店联源','微擎科技'][i%6]+(i>5?' '+(Math.floor(i/6)+1):''),code:'user_'+(801+i),phone:'137****'+(5600+i),group:i%4===0?'代理商':'主账户',status:i===5?'已停用':'正常',company:['青禾商贸有限公司','云飞供应链有限公司','开兴网络科技'][i%3],points:300+i*20,balance:(140+i*37.5).toFixed(2),fee:'2.50',monthly:30,returnFee:'3.00',agent:['云飞','大牛','花下海'][i%3],type:'普通用户',permissions:['商品采集','在线寄件'],note:''})),oem:['桃花源供应链','店速通','开茂科技','枫谷拍单','洋总代理','臻和网络','贝塔一体仓','佳俊商贸'].map((name,i)=>({id:5001+i,name,code:'26'+(701+i)+'TW'+i,group:'品牌合作',status:'正常',account:'demo_brand_'+(i+1),phone:'136****'+(7400+i),note:'',permissions:[]}))};
const configs={stores:{title:'店铺管理',en:'STORE MANAGEMENT',desc:'集中管理店铺、账号与授权,让日常运营井然有序。',add:'添加店铺',batch:'批量续期',search:'搜索店铺名称 / 店铺 ID',columns:[['name','店铺信息'],['group','所属分组'],['account','子账号'],['status','授权状态'],['expires','到期时间'],['note','备注']],groups:['精选店铺','服饰鞋包','生活百货']},superstores:{title:'超级店铺管理',en:'STORE ADMINISTRATION',desc:'跨账号查看全部店铺,统一管理店铺归属与授权状态。',add:'添加店铺',batch:'批量检测',search:'搜索店铺 / 所属账号 / 店铺 ID',columns:[['name','店铺信息'],['owner','所属账号'],['group','店铺分组'],['account','子账号'],['status','授权状态'],['expires','到期时间']],groups:['精选店铺','服饰鞋包','生活百货']},accounts:{title:'子账号管理',en:'TEAM & ACCESS',desc:'为团队分配独立账号,以清晰的访问权限协同管理店铺。',add:'添加子账号',batch:'批量启用',search:'搜索昵称 / 登录账号',columns:[['name','用户信息'],['phone','登录手机号'],['group','角色分组'],['status','账号状态'],['permissions','可访问页面'],['account','所属主账号']],groups:['运营','客服','仓储']},users:{title:'超级用户管理',en:'USER ADMINISTRATION',desc:'统一管理用户、功能权限与账户信息。',add:'添加用户',batch:'批量启用',search:'搜索用户 / 手机号 / 公司',columns:[['name','用户信息'],['group','用户分组'],['company','公司'],['points','积分'],['balance','快递余额'],['fee','每单快递费'],['status','账号状态']],groups:['主账户','代理商']},oem:{title:'OEM 贴牌管理',en:'BRAND PARTNERS',desc:'管理合作品牌标识与主体账号,建立统一的品牌工作空间。',add:'添加贴牌',batch:'批量启用',search:'搜索贴牌名称 / 贴牌编码',columns:[['name','贴牌名称'],['code','贴牌编码'],['account','主体账号'],['status','状态'],['assets','品牌素材']],groups:['品牌合作']}};
configs.stores.columns=[['name','店铺名称'],['code','店铺 ID'],['created','添加时间'],['expires','到期时间'],['status','授权状态'],['group','分组'],['account','子账号'],['phone','子账号手机号'],['login','登录状态'],['api','API 购买'],['note','备注']];
configs.superstores.columns=[['name','店铺名称'],['code','店铺 ID / 登录号码'],['owner','所属账号'],['account','子账号'],['group','店铺分组'],['login','登录状态'],['status','授权状态'],['api','API 购买'],['secondary','二级超管'],['permanent','永久'],['note','备注']];
configs.accounts.columns=[['name','用户昵称'],['code','登录账号'],['phone','手机号'],['group','分组'],['account','所属主账号'],['status','状态'],['permissions','可访问页面'],['note','备注']];
configs.users.columns=[['name','用户昵称'],['phone','手机 / 登录账号'],['group','分组'],['company','公司'],['type','账户类型'],['permissions','功能权限'],['agent','代理名称'],['points','积分'],['monthly','月费耗点'],['balance','快递余额'],['fee','每单快递费'],['returnFee','每单退货费'],['status','状态']];
configs.oem.columns=[['code','贴牌编码'],['name','贴牌名称'],['account','主体账号'],['assets','图标'],['assetsTitle','题图'],['status','状态'],['note','备注']];
let current='stores',tab='',page=1,query='',group='',status='',sortAsc=true,editingId=null,selected=new Set(),hiddenColumns=new Set(),toastTimer;
function isStore(){return current==='stores'||current==='superstores'}
function filtered(){return data[current].filter(r=>(!query||Object.values(r).join(' ').toLowerCase().includes(query))&&(!group||r.group===group)&&(!status||r.status===status)&&(!tab||r.status===tab)).sort((a,b)=>sortAsc?a.name.localeCompare(b.name,'zh-CN'):b.name.localeCompare(a.name,'zh-CN'))}
function visibleRows(){const rows=filtered();return rows.slice((page-1)*Number($('pageSize').value),page*Number($('pageSize').value))}
function showToast(message){$('toast').textContent=message;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').hidden=true,3200)}
function renderTabs(){const tabs=isStore()?[['','全部店铺'],['正常','授权正常'],['即将到期','即将到期'],['已过期','已过期']]:[['','全部'+(current==='oem'?'品牌':'账号')],['正常','正常'],['已停用','已停用']];$('tabs').innerHTML=tabs.map(([value,label])=>`<button class="tab ${tab===value?'active':''}" data-tab="${value}" aria-pressed="${tab===value}">${label}<small>${data[current].filter(r=>!value||r.status===value).length}</small></button>`).join('')}
function cell(r,key){const v=escapeHtml(r[key]);switch(key){case'name':return `<div class="entity"><span class="entity-mark ${r.id%3===0?'alt':''}">${escapeHtml(r.name.slice(0,1))}</span><div><div class="entity-name">${v}</div><small>${escapeHtml(r.code)}</small></div></div>`;case'login':return `<span style="color:${r.login==='登录成功'?'var(--green)':'var(--muted)'}">${v}</span>`;case'status':return `<span class="pill ${r.status==='即将到期'?'warn':r.status==='正常'?'':'error'}">${v==='正常'&&isStore()?'授权正常':v}</span>`;case'group':return `<span class="tag">${v}</span>`;case'expires':return `<span class="date">${v}</span>${r.status==='即将到期'?'<div class="subline" style="color:var(--amber)">5 天后到期</div>':''}`;case'permissions':return `<span class="muted">${escapeHtml(r.permissions.join('、'))||'尚未分配'}</span>`;case'assetsTitle':case'assets':return '<span class="muted">暂未上传</span>';case'balance':case'fee':case'returnFee':return `<span class="date">¥ ${v}</span>`;case'code':case'owner':return `<span class="mono">${v}</span>`;case'note':return `<span class="muted">${v||'—'}</span>`;default:return v||'<span class="muted">—</span>'}}
function renderTable(){const cfg=configs[current],rows=filtered(),size=Number($('pageSize').value);page=Math.max(1,Math.min(page,Math.ceil(rows.length/size)||1));const visible=visibleRows(),cols=cfg.columns.filter(([key])=>!hiddenColumns.has(key));$('thead').innerHTML=`<tr><th><input type="checkbox" id="selectAll" aria-label="选择当前页全部记录" ${visible.length&&visible.every(r=>selected.has(r.id))?'checked':''}></th><th class="row-number">序号</th>${cols.map(([key,label])=>`<th>${key==='name'?`<button class="sort" id="sortName">${label} ${sortAsc?'↑':'↓'}</button>`:label}</th>`).join('')}<th>操作</th></tr>`;$('tbody').innerHTML=visible.length?visible.map((r,index)=>`<tr class="${selected.has(r.id)?'selected':''}"><td><input type="checkbox" data-select="${r.id}" aria-label="选择${escapeHtml(r.name)}" ${selected.has(r.id)?'checked':''}></td><td class="row-number">${(page-1)*size+index+1}</td>${cols.map(([key])=>`<td>${cell(r,key)}</td>`).join('')}<td>${isStore()?`<button class="text row-action" data-store-action="enter" data-id="${r.id}">进入</button><button class="text row-action" data-store-action="renew" data-id="${r.id}">续期</button>`:""}<button class="text row-action" data-detail="${r.id}">${isStore()?'查看':'详情'}</button><button class="text row-action" data-edit="${r.id}">编辑</button></td></tr>`).join(''):`<tr><td class="empty" colspan="${cols.length+3}">没有匹配的记录,请调整筛选条件或重置。</td></tr>`;const all=$('selectAll');all.indeterminate=visible.some(r=>selected.has(r.id))&&!visible.every(r=>selected.has(r.id));$('selection').textContent=selected.size?`已选择 ${selected.size} 项`:'选择记录后可批量操作';$('selection').className=selected.size?'selection':'muted';$('batch').disabled=!selected.size;$('detect').disabled=$('login').disabled=!selected.size;$('resultCount').textContent=`共 ${rows.length} 条记录`;$('range').textContent=rows.length?`显示 ${(page-1)*size+1}–${Math.min(page*size,rows.length)} 条,共 ${rows.length} 条`:'共 0 条记录';$('pageNumber').textContent=`${page} / ${Math.ceil(rows.length/size)||1}`;$('prev').disabled=page===1;$('next').disabled=page*size>=rows.length}
function render(){renderTabs();renderTable()}
function switchPage(key){const area=key==='stores'||key==='accounts'?'operation':key==='oem'?'brand':'admin';document.querySelectorAll('[data-module]').forEach(b=>{b.classList.toggle('active',b.dataset.module===area);b.setAttribute('aria-pressed',b.dataset.module===area)});document.querySelectorAll('[data-area]').forEach(b=>b.hidden=b.dataset.area!==area);$('moduleTitle').textContent={operation:'店铺运营',admin:'超级管理',brand:'品牌管理'}[area];current=key;tab='';page=1;query='';group='';status='';selected.clear();hiddenColumns.clear();const cfg=configs[key];$('detect').hidden=$('login').hidden=!isStore();document.querySelectorAll('[data-page]').forEach(b=>{b.classList.toggle('active',b.dataset.page===key);b.setAttribute('aria-current',b.dataset.page===key?'page':'false')});$('crumb').textContent=$('pageTitle').textContent=cfg.title;$('eyebrow').textContent=cfg.en;$('pageDescription').textContent=cfg.desc;$('add').textContent=cfg.add;$('batch').textContent=cfg.batch;$('keyword').value='';$('keyword').placeholder=cfg.search;$('groupFilter').innerHTML='<option value="">全部分组</option>'+cfg.groups.map(g=>`<option>${g}</option>`).join('');$('statusFilter').innerHTML='<option value="">全部状态</option>'+(isStore()?['正常','即将到期','已过期']:['正常','已停用']).map(s=>`<option>${s}</option>`).join('');render()}
function field(label,name,value='',required=false,type='text'){return `<div class="field"><label for="edit_${name}">${label}${required?' <span style="color:var(--brand)">*</span>':''}</label><input id="edit_${name}" name="${name}" type="${type}" value="${escapeHtml(value)}" maxlength="100" ${required?'required':''}></div>`}
function openEditor(id=null){editingId=id;const r=data[current].find(r=>r.id===id)||{},cfg=configs[current];$('editorTitle').textContent=id?'编辑'+(isStore()?'店铺':current==='oem'?'贴牌':'用户'):cfg.add;let fields=field(isStore()?'店铺名称':current==='oem'?'贴牌名称':'用户昵称','name',r.name,true)+field(isStore()?'店铺 ID':current==='oem'?'贴牌编码':'登录账号','code',r.code,true);if(current==='accounts'||current==='users')fields+=field('手机号','phone',r.phone,true)+field(id?'新密码(留空则不修改)':'密码','password','',!id,'password');fields+=`<div class="field"><label for="edit_group">所属分组</label><select name="group" id="edit_group">${cfg.groups.map(g=>`<option ${r.group===g?'selected':''}>${g}</option>`).join('')}</select></div>`;if(isStore()||current==='oem')fields+=field(current==='oem'?'主体账号':'子账号','account',r.account,current==='oem');if(current==='users')fields+=field('公司','company',r.company);if(!isStore())fields+=`<div class="field"><label for="edit_status">账号状态</label><select name="status" id="edit_status"><option ${r.status!=='已停用'?'selected':''}>正常</option><option ${r.status==='已停用'?'selected':''}>已停用</option></select></div>`;if(current==='accounts'||current==='users')fields+=`<div class="field"><label>可访问页面</label><div class="permissions">${['店铺列表','订单管理','商品采集','在线寄件','评分考核','税务申报'].map(p=>`<label><input type="checkbox" name="permission" value="${p}" ${(r.permissions||[]).includes(p)?'checked':''}>${p}</label>`).join('')}</div></div>`;fields+=field('备注','note',r.note);if(isStore()&&!id)fields+='<div class="help">此处演示手动添加流程。正式系统可在此接入扫码授权;新记录默认使用演示授权。</div>';$('editorFields').innerHTML=fields;$('editor').showModal()}
document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>switchPage(b.dataset.page));
document.querySelectorAll('[data-module]').forEach(b=>b.onclick=()=>switchPage(b.dataset.start));
$('filterForm').onsubmit=e=>{e.preventDefault();query=$('keyword').value.trim().toLowerCase();group=$('groupFilter').value;status=$('statusFilter').value;page=1;selected.clear();renderTable()};
$('reset').onclick=()=>{query=group=status=tab='';page=1;selected.clear();$('keyword').value=$('groupFilter').value=$('statusFilter').value='';render()};
$('tabs').onclick=e=>{const b=e.target.closest('[data-tab]');if(b){tab=b.dataset.tab;page=1;selected.clear();renderTabs();renderTable()}};
$('prev').onclick=()=>{page--;renderTable()};$('next').onclick=()=>{page++;renderTable()};$('pageSize').onchange=()=>{page=1;renderTable()};
$('table').onchange=e=>{if(e.target.id==='selectAll')visibleRows().forEach(r=>e.target.checked?selected.add(r.id):selected.delete(r.id));else if(e.target.dataset.select){const id=Number(e.target.dataset.select);e.target.checked?selected.add(id):selected.delete(id)}renderTable()};
$('table').onclick=e=>{const action=e.target.closest('[data-store-action]');if(action){const r=data[current].find(r=>r.id===Number(action.dataset.id));if(action.dataset.storeAction==='renew'){r.status='正常';r.expires='2027-05-09';render();showToast('已模拟续期:'+r.name)}else{showToast('模拟进入店铺:'+r.name+'(未连接真实店铺)')}return}const edit=e.target.closest('[data-edit]'),detail=e.target.closest('[data-detail]');if(edit)openEditor(Number(edit.dataset.edit));if(detail){const r=data[current].find(r=>r.id===Number(detail.dataset.detail));$('detailTitle').textContent=r.name;$('detailBody').innerHTML=configs[current].columns.map(([key,label])=>`<div><div class="muted" style="font-size:11px;margin-bottom:6px">${label}</div>${cell(r,key)}</div>`).join('')+'<div class="notice">此为本地演示记录,不会连接店铺或读取真实账号。</div>';$('details').showModal()}if(e.target.id==='sortName'){sortAsc=!sortAsc;renderTable()}};
$('density').onclick=()=>{const compact=$('table').classList.toggle('compact');$('density').textContent=compact?'舒适视图':'紧凑视图';$('density').setAttribute('aria-pressed',compact)};
$('columnSettings').onclick=()=>{$('columnOptions').innerHTML=configs[current].columns.map(([key,label])=>`<label><input type="checkbox" value="${key}" ${hiddenColumns.has(key)?'':'checked'} ${key==='name'?'disabled':''}>${label}${key==='name'?'(固定)':''}</label>`).join('');$('columns').showModal()};
$('columnOptions').onchange=e=>{e.target.checked?hiddenColumns.delete(e.target.value):hiddenColumns.add(e.target.value);renderTable()};
$('add').onclick=()=>openEditor();document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('editForm').onsubmit=e=>{e.preventDefault();const values=new FormData(e.target),name=String(values.get('name')).trim(),code=String(values.get('code')).trim();if(!name||!code){showToast('名称和编号不能为空');return}if(data[current].some(r=>r.code===code&&r.id!==editingId)){showToast('该编号已存在,请使用其他编号');return}const existing=data[current].find(r=>r.id===editingId);const record={...(existing||{id:Date.now(),status:'正常',expires:'2027-05-09',owner:'demo_owner_1',points:0,balance:'0.00',fee:'2.50'}),name,code,group:values.get('group'),note:values.get('note'),permissions:values.getAll('permission')};for(const key of ['phone','account','company','status'])if(values.has(key))record[key]=values.get(key);if(existing)Object.assign(existing,record);else data[current].unshift(record);$('editor').close();render();showToast(editingId?'已保存修改(演示)':'已添加记录(演示)')};
$('batch').onclick=()=>{let count=selected.size;data[current].filter(r=>selected.has(r.id)).forEach(r=>{if(current!=='superstores'){r.status='正常';if(current==='stores')r.expires='2027-05-09'}});selected.clear();render();showToast(current==='superstores'?`已完成 ${count} 家店铺的模拟检测`:`已更新 ${count} 条演示记录`)};
$('detect').onclick=()=>{showToast('已模拟检测 '+selected.size+' 家店铺;登录状态已显示在表格中')};
$('login').onclick=()=>{data[current].filter(r=>selected.has(r.id)).forEach(r=>r.login='登录成功');renderTable();showToast('已模拟登录 '+selected.size+' 家店铺')};
$('refresh').onclick=()=>{render();$('loadStatus').textContent='已刷新 · '+new Date().toLocaleTimeString('zh-CN')+' · 本地演示';showToast('列表已刷新')};
$('designNotes').onclick=()=>{$('detailTitle').textContent='为桌面工作流而设计';$('detailBody').innerHTML='<div class="notice">品牌主色 #98638f · 梅紫</div><p>顶部以统一线性图标区分店铺运营、超级管理与品牌管理;左侧仅展示当前业务域内的功能。</p><p>工具栏按记录、选中记录、视图分组。新增和刷新位于左侧,批量操作居中,列设置与行密度位于右侧。筛选和状态切换贴近表格,选择数量在列表右上角显示。</p><p>默认紧凑行高 29px,舒适行高 36px;默认每页 50 条。表头与操作列固定,适合长列表与横向浏览。</p><p>后续 WPF 可使用 DockPanel / Grid 组织顶部模块栏、内容区与底部状态栏,业务页面复用 DataGridEx。此 HTML 仅演示交互,不包含权限校验、真实登录、支付续期或 API 接入。</p>';$('details').showModal()};
switchPage('stores');
console.assert(escapeHtml('<script>')==='&lt;script&gt;','HTML escaping');
console.assert(filtered().length===72,'Initial store count');
query='不存在的店铺';console.assert(filtered().length===0,'Empty search');query='';
status='已过期';console.assert(filtered().every(r=>r.status==='已过期'),'Status filter');status='';
console.assert(new Set(stores.map(r=>r.id)).size===stores.length,'Unique record IDs');
</script>
</body>
</html>
@@ -22,14 +22,7 @@ public sealed class LoginResult
public sealed record CurrentUser(string UserName, string? NickName);
public interface IAuthApi
{
Task<LoginResult> LoginAsync(LoginRequest request, CancellationToken cancellationToken = default);
Task LogoutAsync(CancellationToken cancellationToken = default);
Task<CurrentUser> 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);
@@ -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; }
/// <param name="httpClient">可选的外部 HTTP 客户端,由调用方管理其生命周期。</param>
public StoreApiClient(ApiClientOptions options, HttpClient? httpClient = null)
+1 -8
View File
@@ -4,15 +4,8 @@ using Store.Management.Api.Transport;
namespace Store.Management.Api.Users;
public interface IUserApi
{
Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> GetAsync(string? userName = null, CancellationToken cancellationToken = default);
Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default);
}
internal sealed class UserApi(ApiTransport transport) : IUserApi
public sealed class UserApi(ApiTransport transport)
{
public Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default)
{
@@ -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<StoreApiClient>().Dispose(); }
finally { base.OnExit(e); }
}
finally
{
DispatcherUnhandledException -= OnDispatcherUnhandledException;
@@ -4,6 +4,8 @@
xmlns:f="clr-namespace:Store.Management.Framework.Controls">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MAX.Common.WPF;component/Resources/Generic.xaml" />
<ResourceDictionary Source="/Store.Management.Framework;component/Controls/DataGridEx/DataGridEx.xaml" />
</ResourceDictionary.MergedDictionaries>
@@ -0,0 +1,22 @@
using MAX.Core.DependencyInjection;
using Store.Management.Api;
using Store.Management.Api.Authentication;
namespace Store.Management.Framework.Services;
/// <summary>用户登录服务,复用应用 API 客户端的认证会话。</summary>
public interface ILoginService
{
/// <summary>提交登录;passwordHash 为 UTF-8 密码的 32 位大写 MD5。</summary>
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);
}
@@ -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();
}
}
}
@@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="HandyControl" Version="3.6.2.1" />
<PackageReference Include="MAX.Common.WPF" Version="1.4.23" />
<ProjectReference Include="..\Store.Management.Api\Store.Management.Api.csproj" />
<ProjectReference Include="..\Core\MAX.Core\MAX.Core.csproj" />
<ProjectReference Include="..\Core\MAX.Framework\MAX.Framework.csproj" />
</ItemGroup>
-22
View File
@@ -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<MainWindow, MainWindowViewModel>();
containerRegistry.RegisterDialog<LoginWindow, LoginViewModel>();
//registry.RegisterInstance(CreateApiClient());
// registry.Register<LoginViewModel>(provider => new LoginViewModel(provider.Resolve<StoreApiClient>().Auth));
// registry.RegisterSingleton<SingletonProbe>();
// registry.Register<TransientProbe>();
// registry.RegisterSingleton<NotificationExampleState>();
@@ -89,9 +72,4 @@ public partial class App : FrameworkApplication
// registry.RegisterDialog<FrameworkTestDialog, FrameworkTestDialogViewModel>();
}
protected override void OnExit(ExitEventArgs e)
{
//try { Container?.Resolve<StoreApiClient>().Dispose(); }
//finally { base.OnExit(e); }
}
}
@@ -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;
/// <summary>登录校验、认证请求和窗口生命周期内的请求取消。</summary>
public sealed class LoginViewModel() : ConfirmDialogViewModel, IDisposable
public enum EEE
{
//private readonly IAuthApi _auth = auth ?? throw new ArgumentNullException(nameof(auth));
[Description("管理员")]
Admin,
[Description("用户")]
User
}
/// <summary>登录校验、认证请求和窗口生命周期内的请求取消。</summary>
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)
{
+33 -26
View File
@@ -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">
<StackPanel>
<Label
Padding="0,0,0,8"
Content="用户名"
Target="{Binding ElementName=UserNameBox, Mode=OneWay}" />
<TextBox
x:Name="UserNameBox"
Height="40"
VerticalContentAlignment="Center"
AutomationProperties.Name="用户名"
IsEnabled="{Binding CanEdit, Mode=OneWay}"
MaxLength="{x:Static vm:LoginViewModel.InputMaxLength}"
Text="{Binding UserName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Label
Padding="0,20,0,8"
Content="密码"
Target="{Binding ElementName=PasswordBox, Mode=OneWay}" />
<PasswordBox
x:Name="PasswordBox"
Height="40"
VerticalContentAlignment="Center"
AutomationProperties.Name="密码"
IsEnabled="{Binding CanEdit, Mode=OneWay}"
MaxLength="{x:Static vm:LoginViewModel.InputMaxLength}"
PasswordChanged="OnPasswordChanged" />
<wpf:SpacingStackPanel Orientation="Vertical" VerticalSpacing="10">
<ComboBox
wpf:EnumBindingExtension.SelectedEnum="{Binding ee, Mode=TwoWay}"
ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type vm:EEE},
Converter={StaticResource EnumDisplayConverter}}"
SelectedValuePath="Value" />
<wpf:TitleContent
Title="用户名"
Placement="Left"
TitleVerticalAlignment="Center"
TitleWidth="50">
<TextBox
VerticalContentAlignment="Center"
AutomationProperties.Name="用户名"
IsEnabled="{Binding CanEdit, Mode=OneWay}"
MaxLength="{x:Static vm:LoginViewModel.InputMaxLength}"
Text="{Binding UserName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<wpf:TitleContent
Title="密码"
Placement="Left"
TitleVerticalAlignment="Center"
TitleWidth="50">
<PasswordBox
x:Name="PasswordBox"
VerticalContentAlignment="Center"
AutomationProperties.Name="密码"
IsEnabled="{Binding CanEdit, Mode=OneWay}"
MaxLength="{x:Static vm:LoginViewModel.InputMaxLength}"
PasswordChanged="OnPasswordChanged" />
</wpf:TitleContent>
<TextBlock
Margin="0,12,0,0"
AutomationProperties.LiveSetting="Polite"
@@ -90,7 +97,7 @@
Content="{Binding LoginButtonText, Mode=OneWay}"
IsDefault="True"
Style="{StaticResource ButtonPrimary}" />
</StackPanel>
</wpf:SpacingStackPanel>
</Border>
<TextBlock
Margin="0,20,0,0"
@@ -14,7 +14,6 @@ public partial class LoginWindow : UserControl
{
viewModel.ClearPasswordRequested += OnClearPasswordRequested;
}
UserNameBox.Focus();
}
private void OnPasswordChanged(object sender, RoutedEventArgs e)
@@ -1,3 +1,4 @@
using Autofac;
using System.Windows;
using System.Windows.Threading;
using Store.Management;
@@ -19,7 +20,46 @@ class Program
var handler = new LoginHandler();
using var httpClient = new HttpClient(handler);
using var client = new StoreApiClient(new ApiClientOptions { BaseUrl = new Uri("http://localhost:14312/api/") }, httpClient);
var app = new App(client);
if (args.Contains("--login-service"))
{
var extension = new Prism.Autofac.AutofacContainerExtension();
extension.FinalizeExtension();
using (var productionContainer = extension.Instance)
{
var productionClient = productionContainer.Resolve<StoreApiClient>();
Check(ReferenceEquals(productionClient, productionContainer.Resolve<StoreApiClient>()), "自动注册 API 单例");
Check(productionContainer.Resolve<Store.Management.Framework.Services.ILoginService>() != 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<StoreApiClient>()), "保留测试客户端注册");
var service = container.Resolve<Store.Management.Framework.Services.ILoginService>();
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);
}
}