提交用户部分代码

This commit is contained in:
chfei committed 2026-11-13 15:51:16 +08:00
1 parent 9b9e4cbd82
commit 127fc22de5
26 files changed
+1110 -206

No files matched your search

+34 -6
View File
@@ -6,6 +6,8 @@ globs: "**/*.xaml"
# XAML 视图编写约定
项目 XAML 默认不添加 `AutomationProperties.Name`。
## View XAML 头部模板
```XML
@@ -13,6 +15,8 @@ globs: "**/*.xaml"
x:Class="xxxxx.(xxxx)?.Views.XxxView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:xxxx.(xxxx)?.Views"
xmlns:core="MAX.Core"
xmlns:hc="https://handyorg.github.io/handycontrol"
@@ -23,20 +27,26 @@ globs: "**/*.xaml"
d:DataContext="{d:DesignInstance Type=local:XxxViewModel}"
d:Background="White"
d:DesignHeight="450"
d:DesignWidth="800">
d:DesignWidth="800"
mc:Ignorable="d"
>
```
## Dialog View XAML
```XML
<UserControl
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:core="MAX.Core"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:wpf="MAX.WPF"
d:Background="White"
d:DataContext="{d:DesignInstance Type=local:DialogViewModel}"
Style="{StaticResource DialogStyle.Base}"
DataContext="{core:DISource Type=local:DialogViewModel}">
DataContext="{core:DISource Type=local:DialogViewModel}"
mc:Ignorable="d"
>
```
- `{core:DISource Type=local:XxxViewModel}` 绑定 ViewModel
@@ -47,9 +57,12 @@ DataContext="{core:DISource Type=local:DialogViewModel}">
### 控件必须有设计数据
例如
TextBlock > d:Text="TextBlock"
ComboBox > d:ItemsSource="{d:SampleData ItemCount=5}"
页面、弹窗和 `DataTemplate` 应能在 XAML 设计器中直接看出字段内容、排列方向、对齐和间距。设计值使用 `d:` 命名空间,根元素声明 `xmlns:d`、`xmlns:mc` 和 `mc:Ignorable="d"`,保留运行时绑定。
- `TextBox`、`TextBlock` 的文本来自绑定或默认为空时,必须设置有业务含义的非空示例,如 `d:Text="示例商贸有限公司"`。静态文案已可见时无需重复填写。
- 绑定集合的 `ItemsControl`、`ListBox`、`ComboBox`、`DataGridEx` 等必须提供设计时数据,默认使用 `d:ItemsSource="{d:SampleData ItemCount=5}"`。行数可按实际布局调整。下拉框要预览选中项时可加 `d:SelectedIndex="0"`。
- 其他控件按实际显示属性提供设计值,例如绑定标题的 `wpf:TitleContent` 用 `d:Title="中转仓快递费(元/单)"`,金额输入框用 `d:Value="2.20"`,绑定按钮文案用 `d:Content="保存"`。运行时按状态显示的区域,可用 `d:Visibility="Visible"` 预览。
- 集合模板既要能看到多行,也要能看清每行内容。若样本数据未填充自定义控件的显示属性,在模板内补相应的 `d:` 值。
## BindingProxy + CommandProxy(DataTemplate/ContextMenu 绑定)
@@ -284,12 +297,27 @@ C# 侧:`"#FF5733".toColor()`、`"#80000000".toColor()`。
- 需要行列划分、跨行跨列、重叠或虚拟化时,使用满足这些要求的布局容器,不强行替换为 `SpacingStackPanel`。
- 属性及用法见 [max-common-wpf SpacingStackPanel 参考](../skills/max-common-wpf/references/api.md#controlsspacingstackpanelcs)。
### ItemsControl 的顺序排版
少量集合项按行或列顺序排列时,必须在 `ItemsControl.ItemsPanel` 的 `ItemsPanelTemplate` 中使用 `wpf:SpacingStackPanel`。项与项之间的间距由这个面板控制;`ItemTemplate` 负责单项内部布局。优先复用已有间距样式,例如计费弹窗的 `VerticalSpacingPanel4`。需要虚拟化的长列表保留虚拟化面板。
```xml
<ItemsControl ItemsSource="{Binding Fields, Mode=OneWay}"
d:ItemsSource="{d:SampleData ItemCount=5}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<wpf:SpacingStackPanel Style="{StaticResource VerticalSpacingPanel4}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
```
## 文字与简单内容布局
**页面排版遇到“说明文字 + 简单内容”(如输入框、选择器、按钮等输入或点击控件)时,优先使用 `wpf:TitleContent`。** 将说明文字放在 `Title`,交互控件作为内容,避免为同一组合另用 `TextBlock` 配合 `Grid` / `StackPanel` 手工拼接。
- 按布局设置 `Placement`;左侧标题表单优先使用 `Placement="Left"`,并按需设置 `TitleWidth`、`TitleVerticalAlignment="Center"` 和 `LeftTitlePadding`,保持同组字段对齐。
- 内容控件仍自行配置绑定、命令、输入长度和可访问名称;`TitleContent` 只负责标题与内容的布局。
- 内容控件仍自行配置绑定、命令和输入长度;`TitleContent` 只负责标题与内容的布局。
- 属性及用法见 [max-common-wpf TitleContent 参考](../skills/max-common-wpf/references/api.md#controlstitlecontentcs)。
## 布局注释
+8 -1
View File
@@ -20,9 +20,13 @@ public sealed class UserApi(ApiTransport transport)
public Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
return transport.GetRequiredAsync<UserInfo>("user/AddUser", Method.Post, request, cancellationToken: cancellationToken);
string route = request.GroupId is null or UserGroup.MainAccount ? "user/AddUser" : "user/AddSubUser";
return transport.GetRequiredAsync<UserInfo>(route, Method.Post, request, cancellationToken: cancellationToken);
}
public Task ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default)
=> transport.SendAsync<object>("user/ChangePassword", Method.Post, request, cancellationToken: cancellationToken);
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(id);
@@ -31,6 +35,9 @@ public sealed class UserApi(ApiTransport transport)
cancellationToken: cancellationToken);
}
public Task<UserInfo> UpdateBillingAsync(UserBillingUpdateRequest request, CancellationToken cancellationToken = default)
=> transport.GetRequiredAsync<UserInfo>("user/UpdateUser", Method.Post, request, cancellationToken: cancellationToken);
public Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Store.Management.Api.Users
{
public enum UserGroup : byte
{
/// <summary>主号。</summary>
[Description("主号")] MainAccount = 3,
/// <summary>客服。</summary>
[Description("客服")] CustomerService = 4,
/// <summary>加盟(旧系统分组 5)。</summary>
}
}
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Store.Management.Api.Models;
namespace Store.Management.Api.Users;
@@ -12,6 +13,10 @@ public sealed class UserSearchRequest : PageRequest
public class UserEditRequest
{
public UserGroup? GroupId { get; set; }
public UserAdminLevel? SaLevel { get; set; }
[StringLength(50)] public string? OemId { get; set; }
[StringLength(1000)] public string? Authority { get; set; }
[StringLength(50)] public string? NickName { get; set; }
[StringLength(30)] public string? Mobile { get; set; }
[StringLength(100)] public string? CompanyName { get; set; }
@@ -25,11 +30,34 @@ public sealed class UserCreateRequest : UserEditRequest
[StringLength(20)] public string? ParentUserName { get; set; }
}
public sealed record ChangePasswordRequest(string UserName, string NewPassword, string? OldPassword = null);
public enum UserAdminLevel : short
{
[System.ComponentModel.Description("普通用户")] Normal = 0,
[System.ComponentModel.Description("超级管理员")] SuperAdmin = 1
}
public sealed class UserUpdateRequest : UserEditRequest
{
[Range(1, int.MaxValue)] public int Id { get; set; }
}
public sealed class UserBillingUpdateRequest
{
[Range(1, int.MaxValue)] public int Id { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? ShopPointsPerMonth { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public decimal? PostagePerOrder { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public decimal? RefundPostagePerOrder { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public decimal? GiftOemAmountPerOrder { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public decimal? SyncLogisticsPerOrder { get; set; }
}
/// <summary>管理端当前使用的用户资料字段;不接收密码。</summary>
public sealed class UserInfo
{
@@ -41,6 +69,16 @@ public sealed class UserInfo
public string? Notes { get; init; }
public string? ParentUserName { get; init; }
public int GroupId { get; init; }
[System.Text.Json.Serialization.JsonIgnore]
public UserGroup? Group => GroupId is >= byte.MinValue and <= byte.MaxValue ? (UserGroup)GroupId : null;
public UserAdminLevel? SaLevel { get; init; }
public string? OemId { get; init; }
public string? Authority { get; init; }
public int ShopPointsPerMonth { get; init; }
public decimal? PostagePerOrder { get; init; }
public decimal? RefundPostagePerOrder { get; init; }
public decimal? GiftOemAmountPerOrder { get; init; }
public decimal? SyncLogisticsPerOrder { get; init; }
public decimal Points { get; init; }
public decimal? Balance { get; init; }
}
@@ -24,7 +24,7 @@ public sealed class FolderConfig : IFolderConfig
/// <summary>当前用户的数据根目录:%LOCALAPPDATA%\Store.Management。</summary>
public static string AppFolder => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppName);
//, MAX.Core.Cryptography.MD5Helper.Md5_32(ExeFolder)
public static string ConfigFolder => Path.Combine(AppFolder, "Config");
public static string DataFolder => Path.Combine(AppFolder, "Data");
public static string LoggingFolder => Path.Combine(AppFolder, "Logs");
@@ -602,7 +602,6 @@
HorizontalAlignment="Center"
VerticalAlignment="Center"
HorizontalContentAlignment="Center"
AutomationProperties.Name="全选行"
Command="{x:Static ext:DataGridEx.ToggleSelectAllCommand}"
CommandTarget="{Binding RelativeSource={RelativeSource AncestorType={x:Type ext:DataGridEx}}}"
IsChecked="{Binding AreAllRowsSelected, RelativeSource={RelativeSource AncestorType={x:Type ext:DataGridEx}}, Mode=OneWay}">
@@ -992,7 +991,6 @@
<CheckBox
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="选择行"
IsChecked="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=DataGridRow}, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource DataGridExCheckBoxStyle}" />
</Border>
@@ -5,7 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Store.Management.Framework.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="DialogWindow"
Title="{Binding Title}"
Width="800"
Height="450"
mc:Ignorable="d">
@@ -0,0 +1,127 @@
using MAX.Common.Helpers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
namespace Store.Management.Framework.Controls
{
internal class HorizontalIconText : Control
{
public string Icon
{
get => (string)GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
public static readonly DependencyProperty IconProperty = DependencyProperty.Register(
nameof(Icon),
typeof(string),
typeof(HorizontalIconText),
new PropertyMetadata(null, OnOnIconChanged)
);
public bool ShowIcon
{
get => (bool)GetValue(ShowIconProperty);
private set => SetValue(ShowIconPropertyKey, value);
}
private static readonly DependencyPropertyKey ShowIconPropertyKey = DependencyProperty.RegisterReadOnly(
nameof(ShowIcon),
typeof(bool),
typeof(HorizontalIconText),
new PropertyMetadata(false)
);
public static readonly DependencyProperty ShowIconProperty = ShowIconPropertyKey.DependencyProperty;
public bool ShowSpace
{
get => (bool)GetValue(ShowSpaceProperty);
private set => SetValue(ShowSpacePropertyKey, value);
}
private static readonly DependencyPropertyKey ShowSpacePropertyKey = DependencyProperty.RegisterReadOnly(
nameof(ShowSpace),
typeof(bool),
typeof(HorizontalIconText),
new PropertyMetadata(true)
);
public static readonly DependencyProperty ShowSpaceProperty = ShowSpacePropertyKey.DependencyProperty;
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
public static readonly DependencyProperty TextProperty = DependencyProperty.Register(
nameof(Text),
typeof(string),
typeof(HorizontalIconText),
new PropertyMetadata(string.Empty)
);
public double IconSize
{
get => (double)GetValue(IconSizeProperty);
set => SetValue(IconSizeProperty, value);
}
public static readonly DependencyProperty IconSizeProperty = DependencyProperty.Register(
nameof(IconSize),
typeof(double),
typeof(HorizontalIconText),
new PropertyMetadata(14d)
);
public double Spacing
{
get => (double)GetValue(SpacingProperty);
set => SetValue(SpacingProperty, value);
}
public static readonly DependencyProperty SpacingProperty = DependencyProperty.Register(
nameof(Spacing),
typeof(double),
typeof(HorizontalIconText),
new PropertyMetadata(4d)
);
private static void OnOnIconChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is HorizontalIconText view)
{
view.ShowIcon = view.Icon.IsNotBlank();
}
}
protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e)
{
base.OnPropertyChanged(e);
if (e.Property == ShowSpaceProperty)
{
return;
}
ShowSpace = ShowIcon && Text.IsNotBlank();
}
}
}
@@ -72,7 +72,7 @@ internal static class ViewLifecycle
if (ReferenceEquals(previous, next)) return;
view.SetValue(LoadedViewModelProperty, next);
previous?.OnUnloaded();
next?.OnLoaded();
next?.NotifyLoaded();
}
private static void OnUnloaded(object sender, RoutedEventArgs e)
@@ -5,6 +5,20 @@ namespace Store.Management.Framework.Controls;
public abstract class ViewModelBase : BindableBase
{
private bool hasLoaded;
internal void NotifyLoaded()
{
if (!hasLoaded)
{
hasLoaded = true;
OnLoadedOnce();
}
OnLoaded();
}
/// <summary>当前实例首次加载时调用,先于 OnLoaded。</summary>
protected virtual void OnLoadedOnce() { }
protected internal virtual void OnLoaded() { }
protected internal virtual void OnUnloaded() { }
protected internal virtual void OnActivated() { }
@@ -1,8 +1,12 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:f="clr-namespace:Store.Management.Framework.Controls"
xmlns:wpf="MAX.WPF">
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wpf="MAX.WPF"
mc:Ignorable="d">
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Fonts/FontGeometry.xaml" />
<ResourceDictionary Source="pack://application:,,,/MAX.Common.WPF;component/Resources/Generic.xaml" />
@@ -37,6 +41,180 @@
<Style BasedOn="{StaticResource WindowBase}" TargetType="f:WindowBase" />
<Style BasedOn="{StaticResource DialogWindowStyle}" TargetType="f:DialogWindow" />
<!--<Style x:Key="HorizontalSpacingPanel" TargetType="hc:UniformSpacingPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalSpacing" Value="2" />
<Setter Property="ItemVerticalAlignment" Value="Center" />
</Style>-->
<!--<Style x:Key="HorizontalSpacingPanel2" TargetType="hc:UniformSpacingPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalSpacing" Value="4" />
<Setter Property="ItemVerticalAlignment" Value="Center" />
</Style>
<Style x:Key="VerticalSpacingPanel" TargetType="hc:UniformSpacingPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="VerticalSpacing" Value="2" />
</Style>
<Style x:Key="VerticalSpacingPanel2" TargetType="hc:UniformSpacingPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="VerticalSpacing" Value="4" />
</Style>-->
<Style x:Key="HorizontalSpacingPanel" TargetType="wpf:SpacingStackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalSpacing" Value="2" />
<Setter Property="ItemVerticalAlignment" Value="Center" />
</Style>
<Style x:Key="HorizontalSpacingPanel4" TargetType="wpf:SpacingStackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalSpacing" Value="4" />
<Setter Property="ItemVerticalAlignment" Value="Center" />
</Style>
<Style x:Key="HorizontalSpacingPanel8" TargetType="wpf:SpacingStackPanel">
<Setter Property="Orientation" Value="Horizontal" />
<Setter Property="HorizontalSpacing" Value="8" />
<Setter Property="ItemVerticalAlignment" Value="Center" />
</Style>
<Style x:Key="VerticalSpacingPanel" TargetType="wpf:SpacingStackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="VerticalSpacing" Value="2" />
</Style>
<Style x:Key="VerticalSpacingPanel4" TargetType="wpf:SpacingStackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="VerticalSpacing" Value="4" />
</Style>
<Style x:Key="VerticalSpacingPanel8" TargetType="wpf:SpacingStackPanel">
<Setter Property="Orientation" Value="Vertical" />
<Setter Property="VerticalSpacing" Value="8" />
</Style>
<Style
x:Key="HorizontalIconText"
BasedOn="{StaticResource BaseStyle}"
TargetType="f:HorizontalIconText">
<Setter Property="Spacing" Value="4" />
<Setter Property="Focusable" Value="False" />
<Setter Property="IconSize" Value="16" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="f:HorizontalIconText">
<Border>
<DockPanel>
<TextBlock
FontSize="{TemplateBinding IconSize}"
Style="{StaticResource TextBlockIcon}"
Text="{TemplateBinding Icon}"
Visibility="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=ShowIcon, Converter={StaticResource Boolean2VisibilityConverter}}" />
<Border Width="{TemplateBinding Spacing}" Visibility="{Binding RelativeSource={RelativeSource Mode=TemplatedParent}, Path=ShowSpace, Converter={StaticResource Boolean2VisibilityConverter}}" />
<TextBlock
VerticalAlignment="Center"
Text="{TemplateBinding Text}"
TextTrimming="CharacterEllipsis" />
</DockPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style BasedOn="{StaticResource HorizontalIconText}" TargetType="f:HorizontalIconText" />
<Style x:Key="Dialog" TargetType="UserControl">
<Setter Property="Padding" Value="0" />
<Setter Property="Focusable" Value="True" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Background" Value="White" />
</Style>
<Style
x:Key="DialogButtonStyle"
BasedOn="{StaticResource ButtonDefault}"
TargetType="Button">
<Setter Property="MinWidth" Value="70" />
<Setter Property="Height" Value="26" />
</Style>
<Style
x:Key="DialogPrimaryButtonStyle"
BasedOn="{StaticResource ButtonPrimary}"
TargetType="Button">
<Setter Property="MinWidth" Value="70" />
<Setter Property="Height" Value="26" />
</Style>
<Style
x:Key="ConfirmDialog"
BasedOn="{StaticResource Dialog}"
TargetType="UserControl">
<Setter Property="Background" Value="White" />
<Setter Property="Padding" Value="10" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="UserControl">
<Border
d:DataContext="{d:DesignInstance Type=f:ConfirmDialogViewModel}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="auto" />
</Grid.RowDefinitions>
<Border Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
<Border
Grid.Row="1"
Padding="5,8"
BorderBrush="{StaticResource BorderBrush}"
BorderThickness="0,1,0,0">
<Grid>
<Border Padding="2,0">
<f:HorizontalIconText
d:Text="错误信息"
Foreground="{StaticResource DangerBrush}"
Icon="{StaticResource txt-warning}"
Text="{Binding ErrorMsg}"
Visibility="{Binding ErrorMsg, Converter={StaticResource String2VisibilityConverter}}" />
</Border>
<wpf:SpacingStackPanel HorizontalAlignment="Right" Style="{StaticResource HorizontalSpacingPanel4}">
<Button Command="{Binding ConfirmCommand}" Style="{StaticResource DialogPrimaryButtonStyle}">
<TextBlock Text="确认" />
</Button>
<Button
HorizontalAlignment="Right"
Command="{Binding CancelCommand}"
Style="{StaticResource DialogButtonStyle}">
<TextBlock Text="取消" />
</Button>
</wpf:SpacingStackPanel>
</Grid>
</Border>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="VerticalAlignment"
BasedOn="{StaticResource TitleContentBaseStyle}"
@@ -10,6 +10,8 @@ public interface IUserService
Task<PageResult<UserInfo>> SearchAsync(UserSearchRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default);
Task<UserInfo> UpdateBillingAsync(UserBillingUpdateRequest request, CancellationToken cancellationToken = default);
Task ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
@@ -24,6 +26,12 @@ internal sealed class UserService(StoreApiClient client) : IUserService, ISingle
public Task<UserInfo> UpdateAsync(UserUpdateRequest request, CancellationToken cancellationToken = default)
=> client.Users.UpdateAsync(request, cancellationToken);
public Task<UserInfo> UpdateBillingAsync(UserBillingUpdateRequest request, CancellationToken cancellationToken = default)
=> client.Users.UpdateBillingAsync(request, cancellationToken);
public Task ChangePasswordAsync(ChangePasswordRequest request, CancellationToken cancellationToken = default)
=> client.Users.ChangePasswordAsync(request, cancellationToken);
public Task DeleteAsync(int id, CancellationToken cancellationToken = default)
=> client.Users.DeleteAsync(id, cancellationToken);
}
+2
View File
@@ -7,6 +7,7 @@ using Store.Management.Notifications;
using Store.Management.Services;
using Store.Management.ViewModels;
using Store.Management.Views;
using Store.Management.Views.Dialogs;
using System.Diagnostics;
using System.Windows;
namespace Store.Management;
@@ -76,6 +77,7 @@ public partial class App : FrameworkApplication
{
containerRegistry.RegisterDialogWindow<Framework.Controls.DialogWindow>();
containerRegistry.RegisterDialog<UserBillingDialog, UserBillingDialogViewModel>();
containerRegistry.RegisterDialog<UserEditorDialog, UserEditorDialogViewModel>();
containerRegistry.RegisterDialog<DataGridTestView, DataGridTestViewModel>();
@@ -18,8 +18,40 @@ public sealed class UserEditorDialogViewModel(IUserService userService) : Confir
public const int CompanyLimit = 100;
public const int NotesLimit = 255;
public const int AgentCodeLimit = 50;
public sealed class PermissionOption(string name)
{
public string Name { get; } = name;
public bool IsSelected { get; set; }
}
public PermissionOption[] Permissions { get; } =
[
new("微信回采"), new("货源选择"), new("评分考核"), new("爆款数据"),
new("在线寄件"), new("佣金上货"), new("充值用户"), new("商品采集"),
new("裂变调试"), new("学员数据"), new("定向品牌"), new("活动库存")
];
private UserInfo? user;
private string passwordHash = "";
private string? passwordHash;
private string? confirmationHash;
private string? oldPasswordHash;
public bool ShowOldPassword => !IsCreate && passwordHash != null;
public string? ParentUserName { get; set; }
public bool IsCreatingSubUser => IsCreate && Group != UserGroup.MainAccount;
public UserGroup? Group
{
get;
set
{
if (SetProperty(ref field, value))
{
RaisePropertyChanged(nameof(IsCreatingSubUser));
}
}
}
public UserAdminLevel Level { get; set => SetProperty(ref field, value); }
public string AgentCode { get; set => SetProperty(ref field, value); } = "";
public bool IsCreate => user == null;
public string UserName { get; set => SetProperty(ref field, value); } = "";
public UserEditRequest Draft { get; private set => SetProperty(ref field, value); } = new();
@@ -33,14 +65,25 @@ public sealed class UserEditorDialogViewModel(IUserService userService) : Confir
Id = user.Id, NickName = user.NickName, Mobile = user.Mobile,
CompanyName = user.CompanyName, Notes = user.Notes
};
Group = user == null ? UserGroup.MainAccount : (UserGroup)user.GroupId;
Level = user?.SaLevel ?? UserAdminLevel.Normal;
AgentCode = user?.OemId ?? "";
var granted = (user?.Authority ?? "").Split(',', StringSplitOptions.TrimEntries);
foreach (var option in Permissions)
{
option.IsSelected = granted.Contains(option.Name);
}
Title = IsCreate ? "创建用户" : "编辑用户";
RaisePropertyChanged(nameof(IsCreate));
}
public void UpdatePassword(string password)
public void UpdatePasswords(string password, string confirmation, string oldPassword)
{
passwordHash = password.Length >= 6 && !string.IsNullOrWhiteSpace(password)
passwordHash = password.Length == 0 ? null : password.Length >= 6 && !string.IsNullOrWhiteSpace(password)
? LoginViewModel.HashPassword(password) : "";
oldPasswordHash = oldPassword.Length == 0 ? null : LoginViewModel.HashPassword(oldPassword);
RaisePropertyChanged(nameof(ShowOldPassword));
confirmationHash = confirmation.Length == 0 ? null : LoginViewModel.HashPassword(confirmation);
}
public override bool CanCloseDialog() => !IsLoading;
@@ -48,19 +91,53 @@ public sealed class UserEditorDialogViewModel(IUserService userService) : Confir
private async Task SaveAsync()
{
if (string.IsNullOrWhiteSpace(Draft.NickName) || string.IsNullOrWhiteSpace(Draft.Mobile))
{
ErrorMsg = "请输入昵称和手机号。";
return;
}
if (Draft.Mobile.Trim().Length != 11 || !Draft.Mobile.Trim().All(char.IsAsciiDigit))
{
ErrorMsg = "手机号应为 11 位数字。";
return;
}
if (passwordHash == "" || (IsCreate && passwordHash == null))
{
ErrorMsg = "请输入至少 6 位密码。";
return;
}
if (passwordHash != confirmationHash)
{
ErrorMsg = "两次输入的密码不一致。";
return;
}
if (Group == null || (user != null && Group != (UserGroup)user.GroupId &&
(Group == UserGroup.MainAccount || user.GroupId == (int)UserGroup.MainAccount)))
{
ErrorMsg = "请选择有效分组,已有账户不能切换主号和子账号。";
return;
}
Draft.GroupId = user?.GroupId == (int?)Group ? null : Group;
Draft.SaLevel = Level == (user?.SaLevel ?? UserAdminLevel.Normal) ? null : Level;
Draft.OemId = AgentCode.Trim() == (user?.OemId ?? "") ? null : AgentCode.Trim();
var granted = (user?.Authority ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToHashSet();
bool changed = false;
foreach (var option in Permissions)
{
changed |= option.IsSelected ? granted.Add(option.Name) : granted.Remove(option.Name);
}
Draft.Authority = changed ? string.Join(",", granted) : null;
if (Draft is UserCreateRequest create)
{
create.UserName = UserName.Trim();
if (create.UserName.Length == 0 || passwordHash.Length == 0)
{
ErrorMsg = "请输入用户名和至少 6 位的初始密码。";
return;
}
create.Password = passwordHash;
create.ParentUserName = IsCreatingSubUser ? ParentUserName : null;
create.UserName = Draft.Mobile.Trim();
create.Password = passwordHash!;
}
IsLoading = true;
ErrorMsg = "";
bool profileSaved = false;
try
{
if (Draft is UserCreateRequest newUser)
@@ -69,12 +146,17 @@ public sealed class UserEditorDialogViewModel(IUserService userService) : Confir
}
else
{
await userService.UpdateAsync((UserUpdateRequest)Draft);
user = await userService.UpdateAsync((UserUpdateRequest)Draft);
profileSaved = true;
if (passwordHash != null)
{
await userService.ChangePasswordAsync(new ChangePasswordRequest(UserName, passwordHash, oldPasswordHash));
}
}
}
catch (ApiException ex)
{
ErrorMsg = ex.Message;
ErrorMsg = profileSaved ? $"资料已保存,密码修改失败:{ex.Message}" : ex.Message;
return;
}
catch (ValidationException ex)
@@ -91,7 +173,7 @@ public sealed class UserEditorDialogViewModel(IUserService userService) : Confir
public override void OnDialogClosed()
{
passwordHash = "";
passwordHash = confirmationHash = oldPasswordHash = null;
if (Draft is UserCreateRequest create)
{
create.Password = "";
@@ -1,5 +1,6 @@
using Prism.Commands;
using Prism.Mvvm;
using Prism.Regions;
using Prism.Services.Dialogs;
using Store.Management.Api.Models;
using Store.Management.Api.Transport;
@@ -7,6 +8,7 @@ using Store.Management.Api.Users;
using Store.Management.Framework.Controls;
using Store.Management.Framework.Services;
using Store.Management.Views;
using Store.Management.Views.Dialogs;
using System.Windows;
using System.Windows.Controls.Primitives;
@@ -14,122 +16,124 @@ namespace Store.Management.ViewModels;
public sealed class UserManagementPageViewModel(IUserService userService, IDialogService dialogs, IStatusBarService statusBar) : ViewModelBase
{
public const int KeywordLimit = 100;
private string appliedKeyword = "";
private int pageSize = 50;
public const int KeywordLimit = 100;
private string appliedKeyword = "";
private UserGroup? appliedGroup;
private int pageSize = 50;
public string Keyword { get; set; } = "";
private bool _canInteract = true;
public bool CanInteract { get => _canInteract; private set => SetProperty(ref _canInteract, value); }
public int Page { get; private set => SetProperty(ref field, value); } = 1;
public int Total { get; private set => SetProperty(ref field, value); }
public IReadOnlyList<UserInfo> Users { get; private set => SetProperty(ref field, value); } = [];
public UserInfo? SelectedUser { get; set => SetProperty(ref field, value); }
public string Keyword { get; set; } = "";
public UserGroup? SelectedGroup { get; set; }
private bool _canInteract = true;
public bool CanInteract { get => _canInteract; private set => SetProperty(ref _canInteract, value); }
public int Page { get; private set => SetProperty(ref field, value); } = 1;
public int Total { get; private set => SetProperty(ref field, value); }
public IReadOnlyList<UserInfo> Users { get; private set => SetProperty(ref field, value); } = [];
public UserInfo? SelectedUser { get; set => SetProperty(ref field, value); }
public AsyncDelegateCommand SearchCommand => field ??= new(() => LoadAsync(Keyword.Trim(), 1, pageSize));
public AsyncDelegateCommand<DataGridPageRequest> PageCommand => field ??= new(
request => LoadAsync(appliedKeyword, request.PageIndex, request.PageSize), _ => CanInteract);
public AsyncDelegateCommand CreateCommand => field ??= new(() => OpenEditorAsync(null));
public AsyncDelegateCommand EditCommand => field ??= new AsyncDelegateCommand(() => OpenEditorAsync(SelectedUser),
() => SelectedUser != null).ObservesProperty(() => SelectedUser);
public AsyncDelegateCommand DeleteCommand => field ??= new AsyncDelegateCommand(DeleteAsync,
() => SelectedUser != null).ObservesProperty(() => SelectedUser);
public AsyncDelegateCommand LoginCommand => field ??= new(LoginAsync);
protected override void OnLoaded()
public AsyncDelegateCommand SearchCommand => field ??= new(() => LoadAsync(Keyword.Trim(), SelectedGroup, 1, pageSize));
public AsyncDelegateCommand<DataGridPageRequest> PageCommand => field ??= new(
request => LoadAsync(appliedKeyword, appliedGroup, request.PageIndex, request.PageSize), _ => CanInteract);
public AsyncDelegateCommand CreateCommand => field ??= new(() => OpenEditorAsync(null));
public AsyncDelegateCommand EditCommand => field ??= new AsyncDelegateCommand(() => OpenEditorAsync(SelectedUser),
() => SelectedUser != null).ObservesProperty(() => SelectedUser);
public AsyncDelegateCommand BillingCommand => field ??= new AsyncDelegateCommand(async () =>
{
base.OnLoaded();
ButtonResult result = ButtonResult.None;
dialogs.ShowDialog(nameof(UserBillingDialog), SelectedUser!, response => result = response.Result);
if (result == ButtonResult.OK)
{
await LoadAsync(appliedKeyword, appliedGroup, Page, pageSize);
}
}, () => SelectedUser != null).ObservesProperty(() => SelectedUser);
public AsyncDelegateCommand DeleteCommand => field ??= new AsyncDelegateCommand(DeleteAsync,
() => SelectedUser != null).ObservesProperty(() => SelectedUser);
protected override void OnLoadedOnce()
{
_ = LoadAsync(Keyword.Trim(), SelectedGroup, 1, pageSize);
}
private async Task LoginAsync()
{
ButtonResult result = ButtonResult.None;
dialogs.ShowDialog(nameof(LoginWindow), new DialogParameters(), response => result = response.Result);
if (result == ButtonResult.OK)
{
await LoadAsync(Keyword.Trim(), 1, pageSize);
}
}
private async Task LoadAsync(string keyword, int page, int size)
{
if (!CanInteract)
{
return;
}
CanInteract = false;
try
{
statusBar.Report("正在查询用户…");
UserSearchRequest request = new() { Keyword = keyword, Page = page, PageSize = size };
PageResult<UserInfo> result = await userService.SearchAsync(request);
// 删除末页最后一条记录后,按服务端总数回到有效页。
int lastPage = (int)Math.Clamp((result.Total - 1) / size + 1, 1, page);
if (page > lastPage)
{
request.Page = lastPage;
result = await userService.SearchAsync(request);
}
appliedKeyword = keyword;
pageSize = size;
SelectedUser = null;
Users = result.Items;
Total = (int)Math.Min(result.Total, int.MaxValue);
Page = request.Page;
private async Task LoadAsync(string keyword, UserGroup? group, int page, int size)
{
if (!CanInteract)
{
return;
}
CanInteract = false;
try
{
statusBar.Report("正在查询用户…");
UserSearchRequest request = new() { Keyword = keyword, GroupId = (int?)group, Page = page, PageSize = size };
PageResult<UserInfo> result = await userService.SearchAsync(request);
// 删除末页最后一条记录后,按服务端总数回到有效页。
int lastPage = (int)Math.Clamp((result.Total - 1) / size + 1, 1, page);
if (page > lastPage)
{
request.Page = lastPage;
result = await userService.SearchAsync(request);
}
appliedKeyword = keyword;
appliedGroup = group;
pageSize = size;
SelectedUser = null;
Users = result.Items;
Total = (int)Math.Min(result.Total, int.MaxValue);
Page = request.Page;
statusBar.Report($"用户查询完成,共 {Total} 条。", StatusLevel.Success,100);
}
catch (ApiException ex)
{
SelectedUser = null;
Users = [];
Total = 0;
Page = 1;
appliedKeyword = keyword;
pageSize = size;
statusBar.Report(ex.Message, StatusLevel.Error);
}
finally
{
CanInteract = true;
}
}
statusBar.Report($"用户查询完成,共 {Total} 条。", StatusLevel.Success, 100);
}
catch (ApiException ex)
{
SelectedUser = null;
Users = [];
Total = 0;
Page = 1;
appliedKeyword = keyword;
appliedGroup = group;
pageSize = size;
statusBar.Report(ex.Message, StatusLevel.Error);
}
private async Task OpenEditorAsync(UserInfo? user)
{
ButtonResult result = ButtonResult.None;
dialogs.ShowDialog(nameof(UserEditorDialog), new DialogParameters { { "user", user } },
response => result = response.Result);
if (result == ButtonResult.OK)
{
await LoadAsync(appliedKeyword, user == null ? 1 : Page, pageSize);
}
}
finally
{
CanInteract = true;
}
}
private async Task DeleteAsync()
{
UserInfo user = SelectedUser!;
if (MessageBox.Show($"确定删除用户“{user.UserName}”?", "删除用户", MessageBoxButton.OKCancel,
MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK)
{
return;
}
CanInteract = false;
try
{
statusBar.Report("正在删除用户…");
await userService.DeleteAsync(user.Id);
}
catch (ApiException ex)
{
statusBar.Report(ex.Message, StatusLevel.Error);
return;
}
finally
{
CanInteract = true;
}
await LoadAsync(appliedKeyword, Page, pageSize);
}
private async Task OpenEditorAsync(UserInfo? user)
{
ButtonResult result = ButtonResult.None;
dialogs.ShowDialog(nameof(UserEditorDialog), new DialogParameters { { "user", user } },
response => result = response.Result);
if (result == ButtonResult.OK)
{
await LoadAsync(appliedKeyword, appliedGroup, user == null ? 1 : Page, pageSize);
}
}
private async Task DeleteAsync()
{
UserInfo user = SelectedUser!;
if (MessageBox.Show($"确定删除用户“{user.UserName}”?", "删除用户", MessageBoxButton.OKCancel,
MessageBoxImage.Warning, MessageBoxResult.Cancel) != MessageBoxResult.OK)
{
return;
}
CanInteract = false;
try
{
statusBar.Report("正在删除用户…");
await userService.DeleteAsync(user.Id);
}
catch (ApiException ex)
{
statusBar.Report(ex.Message, StatusLevel.Error);
return;
}
finally
{
CanInteract = true;
}
await LoadAsync(appliedKeyword, appliedGroup, Page, pageSize);
}
}
@@ -77,7 +77,6 @@
Text="当前页实时搜索" />
<TextBox
Width="220"
AutomationProperties.Name="当前页实时搜索"
MaxLength="{x:Static vm:DataGridTestViewModel.SearchTextLimit}"
Text="{Binding LocalKeyword, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="输入即过滤当前页;清空恢复本页数据,不改变分页总数" />
@@ -93,7 +92,6 @@
Text="后台搜索(模拟)" />
<TextBox
Width="220"
AutomationProperties.Name="后台搜索关键字"
MaxLength="{x:Static vm:DataGridTestViewModel.SearchTextLimit}"
Text="{Binding BackendKeyword, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="按名称、ID、分组、子账号、手机号、状态或备注搜索全部样例" />
@@ -0,0 +1,42 @@
<UserControl
x:Class="Store.Management.Views.Dialogs.UserBillingDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wpf="MAX.WPF"
Width="460"
Height="310"
d:Background="White"
Style="{StaticResource ConfirmDialog}"
mc:Ignorable="d">
<ItemsControl
d:ItemsSource="{d:SampleData ItemCount=5}"
IsEnabled="{Binding EnableConfirm, Mode=OneWay}"
ItemsSource="{Binding Fields, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<wpf:SpacingStackPanel Style="{StaticResource VerticalSpacingPanel4}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<wpf:TitleContent
Title="{Binding Label, Mode=OneWay}"
Placement="Left"
TitleVerticalAlignment="Center"
TitleWidth="200">
<hc:NumericUpDown
DecimalPlaces="{Binding DecimalPlaces, Mode=OneWay}"
Increment="{Binding Increment, Mode=OneWay}"
Maximum="{Binding Maximum, Mode=OneWay}"
Minimum="{Binding Minimum, Mode=OneWay}"
TextBoxBase.TextChanged="OnAmountTextChanged"
ValueChanged="OnAmountValueChanged"
Value="{Binding Value, Mode=OneWay}" />
</wpf:TitleContent>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</UserControl>
@@ -0,0 +1,127 @@
using Prism.Commands;
using Prism.Services.Dialogs;
using Store.Management.Api.Transport;
using Store.Management.Api.Users;
using Store.Management.Framework.Controls;
using Store.Management.Framework.Services;
using NumericUpDown = HandyControl.Controls.NumericUpDown;
using System.Globalization;
using System.Windows.Controls;
using System.Windows.Input;
namespace Store.Management.Views.Dialogs;
public partial class UserBillingDialog : UserControl
{
public UserBillingDialog() => InitializeComponent();
private void OnAmountValueChanged(object sender, HandyControl.Data.FunctionEventArgs<double> e)
{
if (sender is NumericUpDown { IsKeyboardFocusWithin: true, DataContext: UserBillingDialogViewModel.BillingField field })
{
field.Value = (decimal)e.Info;
}
}
private void OnAmountTextChanged(object sender, TextChangedEventArgs e)
{
// 显式输入与控件初始值相同的金额时,Value 不会触发变化。
if (sender is NumericUpDown { IsKeyboardFocusWithin: true, DataContext: UserBillingDialogViewModel.BillingField field }
&& e.OriginalSource is TextBox input
&& decimal.TryParse(input.Text, NumberStyles.Number, CultureInfo.CurrentCulture, out decimal amount)
&& amount == field.Value)
{
field.Value = amount;
}
}
}
public sealed class UserBillingDialogViewModel(IUserService userService) : ConfirmDialogViewModel<UserInfo>
{
public sealed class BillingField(string label, decimal? original, decimal minimum,
Action<UserBillingUpdateRequest, decimal> apply, bool integer = false)
{
private readonly decimal? originalValue = original;
public string Label { get; } = label;
public decimal? EnteredValue { get; private set; } = original;
public decimal Value
{
get => EnteredValue ?? minimum;
set => EnteredValue = value;
}
public decimal Minimum => Math.Min(originalValue ?? minimum, minimum);
public decimal Maximum => Math.Max(originalValue ?? minimum, integer ? int.MaxValue : 999999m);
public int DecimalPlaces => integer ? 0 : 2;
public decimal Increment => integer ? 1m : 0.01m;
internal bool ApplyChange(UserBillingUpdateRequest request)
{
if (EnteredValue is not decimal value || value == originalValue)
{
return false;
}
decimal maximum = integer ? int.MaxValue : 999999m;
if (value < minimum || value > maximum || decimal.Round(value, DecimalPlaces) != value)
{
throw new FormatException($"{Label}范围为 {minimum} 至 {maximum}" + (integer ? ",请输入整数。" : ",最多两位小数。"));
}
apply(request, value);
return true;
}
}
public BillingField[] Fields { get; private set => SetProperty(ref field, value); } = [];
public override void OnDialogOpened(IDialogParameters parameters)
{
base.OnDialogOpened(parameters);
Title = $"计费配置 · {Parameter.UserName}";
Fields =
[
new("店铺每月续费积分(积分/月)", Parameter.ShopPointsPerMonth, 30m, (request, value) => request.ShopPointsPerMonth = (int)value, integer: true),
new("中转仓快递费(元/单)", Parameter.PostagePerOrder, 2.20m, (request, value) => request.PostagePerOrder = value),
new("中转仓退货快递费(元/单)", Parameter.RefundPostagePerOrder, 2.50m, (request, value) => request.RefundPostagePerOrder = value),
new("礼物单附加费(元/单)", Parameter.GiftOemAmountPerOrder, 0m, (request, value) => request.GiftOemAmountPerOrder = value),
new("物流同步费(元/单)", Parameter.SyncLogisticsPerOrder, 0m, (request, value) => request.SyncLogisticsPerOrder = value)
];
}
public override bool CanCloseDialog() => !IsLoading;
public override ICommand ConfirmCommand => field ??= new AsyncDelegateCommand(SaveAsync);
private async Task SaveAsync()
{
var request = new UserBillingUpdateRequest { Id = Parameter.Id };
try
{
bool changed = false;
foreach (BillingField field in Fields)
{
changed |= field.ApplyChange(request);
}
if (!changed)
{
OnRequestClose(new DialogResult(ButtonResult.Cancel));
return;
}
ErrorMsg = "";
IsLoading = true;
await userService.UpdateBillingAsync(request);
}
catch (FormatException ex)
{
ErrorMsg = ex.Message;
return;
}
catch (ApiException ex)
{
ErrorMsg = ex.Message;
return;
}
finally
{
IsLoading = false;
}
OnRequestClose(new DialogResult(ButtonResult.OK));
}
}
@@ -58,7 +58,6 @@
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}" />
@@ -72,7 +71,6 @@
<PasswordBox
x:Name="PasswordBox"
VerticalContentAlignment="Center"
AutomationProperties.Name="密码"
IsEnabled="{Binding CanEdit, Mode=OneWay}"
MaxLength="{x:Static vm:LoginViewModel.InputMaxLength}"
PasswordChanged="OnPasswordChanged" />
@@ -76,7 +76,6 @@
d:Visibility="Visible"
hc:BorderElement.CornerRadius="2"
hc:VisualElement.Text=""
AutomationProperties.Name="任务进度"
Foreground="{Binding Path=(TextElement.Foreground), RelativeSource={RelativeSource AncestorType=Grid}}"
Value="{Binding Progress, Mode=OneWay, TargetNullValue=0}">
<ProgressBar.Style>
@@ -1,53 +1,99 @@
<UserControl x:Class="Store.Management.Views.UserEditorDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:wpf="MAX.WPF"
xmlns:vm="clr-namespace:Store.Management.ViewModels" Width="480" Height="460">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="ShowWhenTrue"/>
<Style TargetType="wpf:TitleContent" BasedOn="{StaticResource {x:Type wpf:TitleContent}}">
<Setter Property="Placement" Value="Left"/>
<Setter Property="TitleWidth" Value="80"/>
<Setter Property="TitleVerticalAlignment" Value="Center"/>
</Style>
</UserControl.Resources>
<DockPanel Margin="24" IsEnabled="{Binding EnableConfirm, Mode=OneWay}">
<!-- 保存结果与操作 -->
<wpf:SpacingStackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" HorizontalSpacing="8">
<Button Content="取消" Command="{Binding CancelCommand}"/>
<Button Content="保存" Command="{Binding ConfirmCommand}"/>
</wpf:SpacingStackPanel>
<!-- 用户资料 -->
<wpf:SpacingStackPanel VerticalSpacing="10" Orientation="Vertical">
<wpf:TitleContent Title="用户名">
<TextBox AutomationProperties.Name="用户名" IsEnabled="{Binding IsCreate, Mode=OneWay}"
MaxLength="{x:Static vm:UserEditorDialogViewModel.UserNameLimit}"
Text="{Binding UserName, UpdateSourceTrigger=PropertyChanged}"/>
</wpf:TitleContent>
<wpf:TitleContent Title="初始密码" Visibility="{Binding IsCreate, Mode=OneWay, Converter={StaticResource ShowWhenTrue}}">
<PasswordBox AutomationProperties.Name="初始密码" MaxLength="{x:Static vm:UserEditorDialogViewModel.PasswordLimit}"
PasswordChanged="OnPasswordChanged"/>
</wpf:TitleContent>
<wpf:TitleContent Title="昵称">
<TextBox AutomationProperties.Name="昵称"
MaxLength="{x:Static vm:UserEditorDialogViewModel.NickNameLimit}"
Text="{Binding Draft.NickName, UpdateSourceTrigger=PropertyChanged}"/>
</wpf:TitleContent>
<wpf:TitleContent Title="手机号">
<TextBox AutomationProperties.Name="手机号"
MaxLength="{x:Static vm:UserEditorDialogViewModel.MobileLimit}"
Text="{Binding Draft.Mobile, UpdateSourceTrigger=PropertyChanged}"/>
</wpf:TitleContent>
<wpf:TitleContent Title="公司名称">
<TextBox AutomationProperties.Name="公司名称"
MaxLength="{x:Static vm:UserEditorDialogViewModel.CompanyLimit}"
Text="{Binding Draft.CompanyName, UpdateSourceTrigger=PropertyChanged}"/>
</wpf:TitleContent>
<wpf:TitleContent Title="备注">
<TextBox AutomationProperties.Name="备注"
MaxLength="{x:Static vm:UserEditorDialogViewModel.NotesLimit}"
Text="{Binding Draft.Notes, UpdateSourceTrigger=PropertyChanged}"/>
</wpf:TitleContent>
<TextBlock Text="{Binding ErrorMsg, Mode=OneWay}" Foreground="{DynamicResource DangerBrush}" TextWrapping="Wrap"/>
</wpf:SpacingStackPanel>
</DockPanel>
<UserControl
x:Class="Store.Management.Views.UserEditorDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:users="clr-namespace:Store.Management.Api.Users;assembly=Store.Management.Api"
xmlns:vm="clr-namespace:Store.Management.ViewModels"
xmlns:wpf="MAX.WPF"
Width="500"
Height="620"
Style="{StaticResource ConfirmDialog}"
mc:Ignorable="d">
<UserControl.Resources>
<BooleanToVisibilityConverter x:Key="ShowWhenTrue" />
<Style BasedOn="{StaticResource {x:Type wpf:TitleContent}}" TargetType="wpf:TitleContent">
<Setter Property="Placement" Value="Left" />
<Setter Property="TitleWidth" Value="80" />
<Setter Property="TitleVerticalAlignment" Value="Center" />
</Style>
</UserControl.Resources>
<ScrollViewer IsEnabled="{Binding EnableConfirm, Mode=OneWay}" VerticalScrollBarVisibility="Auto">
<wpf:SpacingStackPanel Orientation="Vertical" VerticalSpacing="8">
<wpf:TitleContent Title="昵称">
<TextBox MaxLength="{x:Static vm:UserEditorDialogViewModel.NickNameLimit}" Text="{Binding Draft.NickName, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<wpf:TitleContent Title="手机号">
<TextBox MaxLength="{x:Static vm:UserEditorDialogViewModel.MobileLimit}" Text="{Binding Draft.Mobile, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<wpf:TitleContent Title="密码">
<PasswordBox
x:Name="NewPassword"
MaxLength="{x:Static vm:UserEditorDialogViewModel.PasswordLimit}"
PasswordChanged="OnPasswordChanged" />
</wpf:TitleContent>
<wpf:TitleContent Title="重复密码">
<PasswordBox
x:Name="Confirmation"
MaxLength="{x:Static vm:UserEditorDialogViewModel.PasswordLimit}"
PasswordChanged="OnPasswordChanged" />
</wpf:TitleContent>
<wpf:TitleContent Title="原密码" Visibility="{Binding ShowOldPassword, Mode=OneWay, Converter={StaticResource ShowWhenTrue}}">
<PasswordBox
x:Name="OldPassword"
MaxLength="{x:Static vm:UserEditorDialogViewModel.PasswordLimit}"
PasswordChanged="OnPasswordChanged"
ToolTip="普通用户修改自己的密码时填写" />
</wpf:TitleContent>
<wpf:TitleContent Title="分组">
<ComboBox
d:ItemsSource="{d:SampleData ItemCount=2}"
DisplayMemberPath="Display"
ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type users:UserGroup},
Converter={StaticResource EnumDisplayConverter}}"
SelectedValue="{Binding Group, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
SelectedValuePath="Value" />
</wpf:TitleContent>
<wpf:TitleContent Title="父账号" Visibility="{Binding IsCreatingSubUser, Mode=OneWay, Converter={StaticResource ShowWhenTrue}}">
<TextBox MaxLength="{x:Static vm:UserEditorDialogViewModel.UserNameLimit}" Text="{Binding ParentUserName, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<wpf:TitleContent Title="公司名称">
<TextBox MaxLength="{x:Static vm:UserEditorDialogViewModel.CompanyLimit}" Text="{Binding Draft.CompanyName, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<wpf:TitleContent Title="代理编码">
<TextBox MaxLength="{x:Static vm:UserEditorDialogViewModel.AgentCodeLimit}" Text="{Binding AgentCode, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<wpf:TitleContent Title="超管等级">
<ComboBox
d:ItemsSource="{d:SampleData ItemCount=2}"
DisplayMemberPath="Display"
ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type users:UserAdminLevel},
Converter={StaticResource EnumDisplayConverter}}"
SelectedValue="{Binding Level, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
SelectedValuePath="Value" />
</wpf:TitleContent>
<wpf:TitleContent Title="权限">
<ItemsControl ItemsSource="{Binding Permissions, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox
Margin="0,2"
Content="{Binding Name, Mode=OneWay}"
IsChecked="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</wpf:TitleContent>
<wpf:TitleContent Title="备注">
<TextBox MaxLength="{x:Static vm:UserEditorDialogViewModel.NotesLimit}" Text="{Binding Draft.Notes, UpdateSourceTrigger=PropertyChanged}" />
</wpf:TitleContent>
<TextBlock Text="新增时使用手机号登录。编辑时密码留空即保留原密码。" TextWrapping="Wrap" />
</wpf:SpacingStackPanel>
</ScrollViewer>
</UserControl>
@@ -10,6 +10,6 @@ public partial class UserEditorDialog : UserControl
private void OnPasswordChanged(object sender, RoutedEventArgs e)
{
((UserEditorDialogViewModel)DataContext).UpdatePassword(((PasswordBox)sender).Password);
((UserEditorDialogViewModel)DataContext).UpdatePasswords(NewPassword.Password, Confirmation.Password, OldPassword.Password);
}
}
@@ -4,7 +4,9 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:f="Store.Management.Framework"
xmlns:hc="https://handyorg.github.io/handycontrol"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:users="clr-namespace:Store.Management.Api.Users;assembly=Store.Management.Api"
xmlns:vm="clr-namespace:Store.Management.ViewModels"
xmlns:wpf="MAX.WPF"
Margin="5"
@@ -13,9 +15,19 @@
d:DesignHeight="760"
d:DesignWidth="1280"
Background="White"
Loaded="OnLoaded"
mc:Ignorable="d">
<UserControl.Resources>
<wpf:BindingProxy x:Key="PageViewModel" Data="{Binding Mode=OneWay}" />
<ContextMenu x:Key="UserRowMenu" DataContext="{Binding Data, Source={StaticResource PageViewModel}, Mode=OneWay}">
<MenuItem Command="{Binding EditCommand}" Header="编辑" />
<MenuItem Command="{Binding BillingCommand}" Header="计费配置" />
<MenuItem Command="{Binding DeleteCommand}" Header="删除" />
<Separator />
<MenuItem Command="{Binding CreateCommand}" Header="创建用户" />
</ContextMenu>
</UserControl.Resources>
<DockPanel IsEnabled="{Binding CanInteract}">
<!-- 查询及用户操作 -->
@@ -30,14 +42,28 @@
<wpf:TitleContent Title="关键词" Style="{StaticResource VerticalAlignment}">
<TextBox
Width="175"
AutomationProperties.Name="用户查询关键词"
hc:InfoElement.Placeholder="用户名,昵称,公司"
MaxLength="{x:Static vm:UserManagementPageViewModel.KeywordLimit}"
Style="{StaticResource TextBoxExtend}"
Text="{Binding Keyword, UpdateSourceTrigger=PropertyChanged}">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SearchCommand}" />
</TextBox.InputBindings>
</TextBox>
</wpf:TitleContent>
<wpf:TitleContent Title="用户组" Style="{StaticResource VerticalAlignment}">
<ComboBox
x:Name="GroupFilter"
Width="120"
d:ItemsSource="{d:SampleData ItemCount=8}"
DisplayMemberPath="Display"
ItemsSource="{wpf:EnumBindingMarkup EnumType={x:Type users:UserGroup},
AllowNull=True,
NullDisplay=全部,
Converter={StaticResource EnumDisplayConverter}}"
SelectedValue="{Binding SelectedGroup, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
SelectedValuePath="Value" />
</wpf:TitleContent>
<Button
Command="{Binding SearchCommand}"
Content="查询"
@@ -50,14 +76,18 @@
<!-- 用户列表与服务端分页 -->
<f:DataGridEx
d:ItemsSource="{d:SampleData ItemCount=8}"
AutoGenerateColumns="False"
BorderThickness="1"
CanUserSortColumns="False"
HorizontalScrollBarVisibility="Auto"
IsReadOnly="True"
ItemsSource="{Binding Users, Mode=OneWay}"
NoDataTips="没有符合条件的用户"
PageChangedCommand="{Binding PageCommand}"
PageSize="50"
RowContextMenu="{StaticResource UserRowMenu}"
SelectRowOnRightClick="True"
SelectedItem="{Binding SelectedUser, UpdateSourceTrigger=PropertyChanged}"
SelectionMode="Single"
ShowCheckBox="False"
@@ -68,22 +98,47 @@
<f:DataGridEx.Columns>
<DataGridTextColumn
Width="*"
MinWidth="140"
Binding="{Binding UserName}"
Header="用户名" />
<DataGridTextColumn
Width="*"
MinWidth="120"
Binding="{Binding NickName}"
Header="昵称" />
<DataGridTextColumn
Width="125"
MinWidth="125"
Binding="{Binding Mobile}"
Header="手机号" />
<DataGridTextColumn
Width="*"
MinWidth="180"
Binding="{Binding CompanyName}"
Header="公司名称" />
<DataGridTextColumn
Width="90"
MinWidth="90"
Binding="{Binding Group, Mode=OneWay, Converter={StaticResource EnumDisplayConverter}}"
Header="分组" />
<DataGridTextColumn
Width="110"
MinWidth="110"
Binding="{Binding OemId, Mode=OneWay}"
Header="代理编码" />
<DataGridTextColumn
Width="100"
MinWidth="100"
Binding="{Binding SaLevel, Mode=OneWay, Converter={StaticResource EnumDisplayConverter}}"
Header="超管等级" />
<DataGridTextColumn
Width="Auto"
MinWidth="220"
Binding="{Binding Authority, Mode=OneWay}"
Header="权限" />
<DataGridTextColumn
Width="*"
MinWidth="160"
Binding="{Binding Notes}"
Header="备注" />
</f:DataGridEx.Columns>
@@ -8,9 +8,4 @@ namespace Store.Management.Views;
public partial class UserManagementPage : UserControl
{
public UserManagementPage() => InitializeComponent();
private async void OnLoaded(object sender, RoutedEventArgs e)
{
//await ((UserManagementPageViewModel)DataContext).SearchCommand.Execute();
}
}
@@ -8,6 +8,7 @@ using Store.Management.Views;
using Store.Management.ViewModels;
using Store.Management.Framework.Controls;
using Store.Management.Api;
using Store.Management.Api.Users;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -75,10 +76,10 @@ class Program
Check(pageVm.Unloads == 1, "移出页面触发卸载");
window.Content = page;
app.Dispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
Check(pageVm.Loads == 2, "重新加入页面再次加载");
Check(pageVm.Loads == 2 && pageVm.FirstLoads == 1, "重新加入页面再次加载");
var replacement = new LifecycleProbe();
page.DataContext = replacement;
Check(pageVm.Unloads == 2 && replacement.Loads == 1, "已加载的页面更换 ViewModel 后转移通知");
Check(pageVm.Unloads == 2 && replacement.Loads == 1 && replacement.FirstLoads == 1, "已加载的页面更换 ViewModel 后转移通知");
windowVm.CancelClose = true;
window.Close();
Check(window.IsVisible && windowVm.Closes == 0, "ViewModel 可以取消关闭");
@@ -142,12 +143,120 @@ class Program
await usersVm.SearchCommand.Execute();
Check(status.Current.Level == StatusLevel.Error && usersVm.CanInteract, "查询失败后显示错误并允许重试");
await usersClient.Auth.LoginAsync(new() { UserName = "admin", Password = "320BB1D43EC042096A83DBAC6DC9294A" });
var usersPage = new UserManagementPage { DataContext = usersVm };
var grid = ((DockPanel)usersPage.Content).Children.OfType<DataGridEx>().Single();
var sample = new UserInfo { GroupId = 4, SaLevel = UserAdminLevel.SuperAdmin, OemId = "A01", Authority = "在线寄件,商品采集" };
foreach (var (header, expected) in new[] { ("分组", "客服"), ("代理编码", "A01"), ("超管等级", "超级管理员"), ("权限", "在线寄件,商品采集") })
{
var column = grid.Columns.OfType<DataGridTextColumn>().Single(c => Equals(c.Header, header));
var cell = new TextBlock { DataContext = sample };
cell.SetBinding(TextBlock.TextProperty, column.Binding);
Check(cell.Text == expected, $"用户列表{header}显示错误:{cell.Text}");
cell.DataContext = new UserInfo { GroupId = 5 };
Check(cell.Text == (header == "分组" ? "5" : ""), $"用户列表{header}历史数据或空值显示错误");
}
var groupFilter = LogicalTreeHelper.FindLogicalNode(usersPage, "GroupFilter") as ComboBox;
Check(groupFilter?.Items.Count == Enum.GetValues<UserGroup>().Length + 1, "用户组包含全部和当前枚举选项");
await app.Dispatcher.InvokeAsync(() => { }, DispatcherPriority.ApplicationIdle);
var menu = grid.RowContextMenu;
Check(grid.SelectRowOnRightClick && ReferenceEquals(menu, usersPage.Resources["UserRowMenu"]), "使用页面行菜单并选中右键行");
var actions = menu.Items.OfType<MenuItem>().Select(item => item.Command).ToArray();
Check(actions.SequenceEqual(new System.Windows.Input.ICommand[] { usersVm.EditCommand, usersVm.BillingCommand, usersVm.DeleteCommand, usersVm.CreateCommand }),
"行菜单绑定编辑、删除和创建用户命令");
Check(groupFilter!.Text == "全部", "默认显示全部用户组");
groupFilter.SelectedIndex = 1;
Check(usersVm.SelectedGroup == Store.Management.Api.Users.UserGroup.MainAccount, "选择主号更新搜索条件");
Task search = usersVm.SearchCommand.Execute();
Check(!usersVm.CanInteract && status.Current.Level == StatusLevel.Info, "查询期间禁用操作");
await search;
Check(usersVm.CanInteract && usersVm.Total == 51 && status.Current.Level == StatusLevel.Success, "重试查询成功");
var narrowWindow = new Window { Content = usersPage, Width = 900, Height = 560, ShowInTaskbar = false };
narrowWindow.Show();
narrowWindow.UpdateLayout();
var listScroll = (ScrollViewer)grid.Template.FindName("DG_ScrollViewer", grid);
Check(listScroll.ComputedHorizontalScrollBarVisibility == Visibility.Visible && listScroll.ScrollableWidth > 0,
"窄窗口显示用户列表横向滚动条");
listScroll.ScrollToRightEnd();
narrowWindow.UpdateLayout();
Check(listScroll.HorizontalOffset > 0 && Math.Abs(listScroll.HorizontalOffset - listScroll.ScrollableWidth) < 1,
"用户列表可滚动到最右列");
narrowWindow.Close();
Check(handler.LastGroupId == 3, "查询按主号分组");
groupFilter.SelectedIndex = 0;
await usersVm.PageCommand.Execute(new DataGridPageRequest(2, 50));
Check(usersVm.Page == 2 && usersVm.CanInteract && status.Current.Level == StatusLevel.Success, "分页查询成功");
Check(handler.LastGroupId == 3, "翻页保留已查询的用户组");
await usersVm.SearchCommand.Execute();
Check(handler.LastGroupId == null && usersVm.Page == 1, "全部清除分组并返回首页");
var billing = new Store.Management.Views.Dialogs.UserBillingDialogViewModel(userService);
billing.OnDialogOpened(new DialogParameters { { "parameter", new UserInfo
{
Id = 7, UserName = "billing", ShopPointsPerMonth = 45, PostagePerOrder = 3.5m,
RefundPostagePerOrder = 4m, GiftOemAmountPerOrder = null, SyncLogisticsPerOrder = 0m
} } });
var billingView = new Store.Management.Views.Dialogs.UserBillingDialog { DataContext = billing };
var billingWindow = new Window { Content = billingView, ShowInTaskbar = false };
billingWindow.Show();
billingWindow.UpdateLayout();
Check(billing.Fields.Length == 5 && billing.Fields[0].Value == 45 && billing.Fields[3].EnteredValue == null &&
billing.Fields[4].EnteredValue == 0, "计费配置回填原值并区分空值和零");
var saveBilling = (Prism.Commands.AsyncDelegateCommand)billing.ConfirmCommand;
int beforeBilling = handler.RequestCount;
await saveBilling.Execute();
Check(handler.RequestCount == beforeBilling, "未改动计费配置不发送请求");
billing.Fields[0].Value = 29;
await saveBilling.Execute();
Check(handler.RequestCount == beforeBilling && billing.ErrorMsg.Length > 0, "拦截无效积分");
billing.Fields[0].Value = 50;
var fields = (ItemsControl)billingView.Content;
var presenter = (ContentPresenter)fields.ItemContainerGenerator.ContainerFromIndex(3);
var title = (MAX.Common.WPF.Controls.TitleContent)System.Windows.Media.VisualTreeHelper.GetChild(presenter, 0);
var amount = (HandyControl.Controls.NumericUpDown)title.Content;
Check(amount.IsEnabled, "空金额可以直接输入");
var amountInput = (TextBox)amount.Template.FindName("PART_TextBox", amount);
amountInput.Focus();
amountInput.SelectAll();
amountInput.SelectedText = "0";
await saveBilling.Execute();
using (var json = System.Text.Json.JsonDocument.Parse(handler.LastUpdateBody!))
{
var body = json.RootElement;
Check(body.EnumerateObject().Count() == 3 && body.GetProperty("id").GetInt32() == 7 &&
body.GetProperty("shopPointsPerMonth").GetInt32() == 50 && body.GetProperty("giftOemAmountPerOrder").GetDecimal() == 0,
"计费更新请求仅含用户 ID 和两项改动");
}
billingWindow.Close();
var editor = new UserEditorDialogViewModel(userService);
editor.OnDialogOpened(new DialogParameters { { "user", null } });
var editorView = new UserEditorDialog { DataContext = editor };
editor.Draft.Mobile = "13800000000";
editor.Draft.NickName = "测试";
editor.Group = UserGroup.CustomerService;
editor.ParentUserName = "parent";
editor.AgentCode = "agent";
editor.Level = UserAdminLevel.SuperAdmin;
editor.Permissions[0].IsSelected = true;
var editorPassword = (PasswordBox)editorView.FindName("NewPassword");
var confirmation = (PasswordBox)editorView.FindName("Confirmation");
editorPassword.Password = "secret";
await ((Prism.Commands.AsyncDelegateCommand)editor.ConfirmCommand).Execute();
Check(editor.ErrorMsg.Contains("不一致"), "密码不一致应阻止保存");
confirmation.Password = "secret";
await ((Prism.Commands.AsyncDelegateCommand)editor.ConfirmCommand).Execute();
Check(handler.SavedCreate is { UserName: "13800000000", GroupId: UserGroup.CustomerService,
ParentUserName: "parent", Authority: "微信回采", OemId: "agent", SaLevel: UserAdminLevel.SuperAdmin },
"新增子账号提交手机号、分组、代理和权限");
editor.OnDialogClosed();
editor = new UserEditorDialogViewModel(userService);
editor.OnDialogOpened(new DialogParameters { { "user", new UserInfo { Id = 1, UserName = "old-login",
GroupId = 3, Authority = "微信回采,历史权限", Mobile = "13800000000", NickName = "测试" } } });
Check(editor.Permissions[0].IsSelected, "编辑回填权限");
editor.Permissions[0].IsSelected = false;
await ((Prism.Commands.AsyncDelegateCommand)editor.ConfirmCommand).Execute();
Check(handler.SavedUpdate?.Authority == "历史权限" && handler.PasswordTarget == null, "保留未知权限,留空不改密");
editor.UpdatePasswords("new-password", "new-password", "");
await ((Prism.Commands.AsyncDelegateCommand)editor.ConfirmCommand).Execute();
Check(handler.PasswordTarget == "old-login", "按原登录名修改密码");
await userService.DeleteAsync(1);
Console.WriteLine("PASS: status bar bindings, colors and background progress reports.");
return;
@@ -267,10 +376,16 @@ class Program
sealed class LifecycleProbe : ViewModelBase
{
public int FirstLoads { get; private set; }
public int Loads { get; private set; }
public int Unloads { get; private set; }
public int Closes { get; private set; }
public bool CancelClose { get; set; }
protected override void OnLoadedOnce()
{
if (Loads != 0) throw new InvalidOperationException("首次加载应先于 OnLoaded");
FirstLoads++;
}
protected override void OnLoaded() => Loads++;
protected override void OnUnloaded() => Unloads++;
protected override void OnClosing(System.ComponentModel.CancelEventArgs e) => e.Cancel = CancelClose;
@@ -279,18 +394,45 @@ sealed class LifecycleProbe : ViewModelBase
sealed class LoginHandler : HttpMessageHandler
{
public UserCreateRequest? SavedCreate { get; private set; }
public string? LastUpdateBody { get; private set; }
public UserUpdateRequest? SavedUpdate { get; private set; }
public string? PasswordTarget { get; private set; }
public int? LastGroupId { get; private set; }
public int RequestCount { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
if (request.RequestUri?.AbsolutePath == "/api/v1/user/GetAllUser")
{
using var query = System.Text.Json.JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken));
LastGroupId = query.RootElement.TryGetProperty("groupId", out var group) && group.ValueKind == System.Text.Json.JsonValueKind.Number
? group.GetInt32() : null;
await Task.Delay(30, cancellationToken);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"code":200,"data":{"total":51,"items":[{"id":1,"userName":"admin"}]}}""", Encoding.UTF8, "application/json")
};
}
string? route = request.RequestUri?.AbsolutePath;
if (route is "/api/v1/user/AddSubUser" or "/api/v1/user/UpdateUser" or "/api/v1/user/ChangePassword")
{
string bodyText = await request.Content!.ReadAsStringAsync(cancellationToken);
var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web);
if (route.EndsWith("/AddSubUser"))
SavedCreate = System.Text.Json.JsonSerializer.Deserialize<UserCreateRequest>(bodyText, options);
else if (route.EndsWith("/UpdateUser"))
{
LastUpdateBody = bodyText;
SavedUpdate = System.Text.Json.JsonSerializer.Deserialize<UserUpdateRequest>(bodyText, options);
}
else
PasswordTarget = System.Text.Json.JsonSerializer.Deserialize<ChangePasswordRequest>(bodyText, options)?.UserName;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"code":200,"data":{"id":1,"userName":"old-login","groupId":3,"authority":"历史权限"}}""", Encoding.UTF8, "application/json")
};
}
if (request.Method == HttpMethod.Post && request.RequestUri?.PathAndQuery == "/api/v1/user/DelUser?id=1")
{
return new HttpResponseMessage(HttpStatusCode.OK)
@@ -54,6 +54,7 @@ internal static class Program
window = new Window { Content = view, Width = 1280, Height = 760, Left = -10000, Top = -10000, ShowInTaskbar = false };
window.Show();
Pump();
CheckBox selectAll = Descendants<CheckBox>(grid).Single(box => box.Command == DataGridEx.ToggleSelectAllCommand);
Border toolbar = (Border)grid.Template.FindName("PART_ToolbarArea", grid);
Border pagination = (Border)grid.Template.FindName("PART_PaginationArea", grid);
Check(grid.BorderThickness == new Thickness(0) && toolbar.BorderThickness == grid.BorderThickness && pagination.BorderThickness == grid.BorderThickness, "borderless demo includes toolbar and pagination");
@@ -70,9 +71,6 @@ internal static class Program
Check(DataGridEx.GetRowNumber(row) == 1, "first row number");
Check(Descendants<TextBlock>(row).Any(text => text.Text == "1"), "row number binding inherits into cell");
Check(Descendants<CheckBox>(row).Any(), "row checkbox rendered independently");
Check(Descendants<CheckBox>(grid).Any(box => System.Windows.Automation.AutomationProperties.GetName(box) == "全选行"), "header checkbox rendered");
CheckBox selectAll = Descendants<CheckBox>(grid).Single(box => System.Windows.Automation.AutomationProperties.GetName(box) == "全选行");
CheckBox selectRow = Descendants<CheckBox>(row).Single();
Check(ReferenceEquals(selectAll.Template, selectRow.Template), "header and row checkbox share the theme template");
double headerCenter = selectAll.TranslatePoint(new Point(selectAll.ActualWidth / 2, 0), grid).X;