使用 MVVM 进行正确验证 [英] Proper validation with MVVM

查看:50
本文介绍了使用 MVVM 进行正确验证的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

警告:很长很详细的帖子.

好的,使用 MVVM 时在 WPF 中进行验证.我现在已经阅读了很多东西,看了很多 SO 问题,并尝试了很多方法,但在某些时候一切都感觉有些不自然,我真的不知道如何去做正确的方式™.

理想情况下,我希望使用 这是默认行为.因此,当文本更改时,绑定会更新源并触发该属性的 IDataErrorInfo 验证;错误被报告回视图.到目前为止一切顺利.

视图模型中的验证状态

有趣的一点是让视图模型或本例中的按钮知道是否有任何错误.IDataErrorInfo 的工作方式,主要是向视图报告错误.因此,该视图可以轻松查看是否有任何错误,显示它们,甚至使用 这对于在 setter 中抛出的异常是必需的.错误消息确实有一个通用文本,所以这可能是一个问题.我已经通过使用 Binding.UpdateSourceExceptionFilter 处理程序,检查抛出的异常并查看源属性,然后生成一个不太通用的错误消息.所有这些都封装到我自己的 Binding 标记扩展中,所以我可以拥有我需要的所有默认值.

所以视野很好.用户犯了一个错误,看到一些错误反馈并可以纠正它.然而,视图模型丢失了.由于绑定引擎抛出异常,源代码从未更新.所以视图模型仍然在旧值上,这不是显示给用户的,并且 IDataErrorInfo 验证显然不适用.

更糟糕的是,视图模型没有好的方法可以知道这一点.至少,我还没有找到一个好的解决方案.有可能是让视图向视图模型报告出现错误.这可以通过数据绑定 来完成Validation.HasError 属性返回给视图模型(这是不可能直接实现的),因此视图模型可以首先检查视图的状态.

另一种选择是将 Binding.UpdateSourceExceptionFilter 中处理的异常中继到视图模型,因此它也会收到通知.视图模型甚至可以为绑定提供一些接口来报告这些事情,允许自定义错误消息而不是通用的每个类型的错误消息.但这会创建从视图到视图模型的更强耦合,我通常希望避免这种情况.

另一个解决方案"是去掉所有类型化的属性,使用普通的string 属性并在视图模型中进行转换.这显然会将所有验证转移到视图模型,但也意味着数据绑定引擎通常会处理大量重复的事情.此外,它会改变视图模型的语义.对我来说,视图是为视图模型构建的,而不是相反——当然视图模型的设计取决于我们想象视图做什么,但是视图如何做到这一点仍然存在普遍的自由.所以视图模型定义了一个 int 属性,因为有一个数字;视图现在可以使用文本框(允许所有这些问题),或使用本机处理数字的东西.所以不,将属性的类型更改为 string 对我来说不是一个选择.

归根结底,这是视图的问题.视图(及其数据绑定引擎)负责为视图模型提供适当的值以供使用.但是在这种情况下,似乎没有什么好的方法可以告诉视图模型它应该使旧的属性值无效.

绑定组

绑定组是我的一种方式试图解决这个问题.绑定组能够对所有验证进行分组,包括 IDataErrorInfo 和抛出的异常.如果视图模型可用,它们甚至可以检查所有这些验证源的验证状态,例如使用 CommitEdit.

默认情况下,绑定组实现上面的选项 2.它们显式地更新绑定,本质上添加了一个额外的未提交状态.因此,当单击按钮时,该命令可以提交这些更改,触发源更新和所有验证,并在成功时获得单个结果.所以命令的动作可能是这样的:

 if (bindingGroup.CommitEdit())保存一切();

CommitEdit 只会在 所有 验证成功时返回 true.它将考虑 IDataErrorInfo 并检查绑定异常.这似乎是选项 2 的完美解决方案.唯一有点麻烦的是用绑定管理绑定组,但我已经为自己构建了一些主要解决这个问题的东西(相关).

如果绑定存在绑定组,则绑定将默认为显式 UpdateSourceTrigger.要使用绑定组实现上面的选择 1,我们基本上必须更改触发器.因为我有一个自定义绑定扩展,所以这很简单,我只是将它设置为 LostFocus.

所以现在,只要文本字段发生变化,绑定仍然会更新.如果源可以更新(绑定引擎不会抛出任何异常),那么 IDataErrorInfo 将照常运行.如果无法更新,视图仍然可以看到它.如果我们点击我们的按钮,底层命令可以调用 CommitEdit(虽然不需要提交任何东西)并获得总验证结果,看看它是否可以继续.

我们可能无法通过这种方式轻松禁用该按钮.至少不是来自视图模型.一遍又一遍地检查验证并不是一个好主意,只是为了更新命令状态,并且当绑定引擎异常被抛出时(这应该禁用按钮)或当它消失时不会通知视图模型再次启用按钮.我们仍然可以使用 Validation.HasError 所以这不是不可能的.

解决方案?

总的来说,这似乎是完美的解决方案.我的问题是什么?老实说,我并不完全确定.绑定组是一个复杂的东西,似乎通常在较小的组中使用,可能在单个视图中具有多个绑定组.通过为整个视图使用一个大绑定组来确保我的验证,感觉好像我在滥用它.我一直在想,必须有更好的方法来解决整个情况,因为肯定不会只有我一个人遇到这些问题.到目前为止,我还没有真正看到很多人使用绑定组来使用 MVVM 进行验证,所以感觉很奇怪.

那么,在能够检查绑定引擎异常的同时,使用 MVVM 在 WPF 中进行验证的正确方法是什么?

<小时>

我的解决方案 (/hack)

首先,感谢您的意见!正如我上面写的,我已经在使用 IDataErrorInfo 来做我的数据验证,我个人认为这是做验证工作最舒服的实用程序.我正在使用类似于 Sheridan 在下面的回答中建议的实用程序,因此维护工作也很好.

最后,我的问题归结为绑定异常问题,视图模型不知道它何时发生.虽然我可以使用上面详述的绑定组来处理这个问题,但我仍然决定反对它,因为我对它感到不太舒服.那我做了什么?

正如我上面提到的,我通过侦听绑定的 UpdateSourceExceptionFilter 在视图端检测绑定异常.在那里,我可以从绑定表达式的 DataItem.然后我有一个接口 IReceivesBindingErrorInformation 它将视图模型注册为可能的接收器,以获取有关绑定错误的信息.然后我使用它来将绑定路径和异常传递给视图模型:

object OnUpdateSourceExceptionFilter(object bindExpression, Exception 异常){BindingExpression expr = (bindExpression as BindingExpression);if (expr.DataItem 是 IReceivesBindingErrorInformation){((IReceivesBindingErrorInformation)expr.DataItem).ReceiveBindingErrorInformation(expr.ParentBinding.Path.Path, 异常);}//检查 FormatException 并产生更好的错误//...}

在视图模型中,每当我收到有关路径绑定表达式的通知时,我都会记得:

HashSetbindingErrors = new HashSet();void IReceivesBindingErrorInformation.ReceiveBindingErrorInformation(字符串路径,异常异常){bindingErrors.Add(path);}

每当 IDataErrorInfo 重新验证一个属性时,我就知道绑定有效,并且我可以从哈希集中清除该属性.

在视图模型中,我可以检查散列集是否包含任何项目并中止任何需要完全验证数据的操作.由于从视图到视图模型的耦合,它可能不是最好的解决方案,但使用该接口至少会减少一些问题.

解决方案

警告:长答案也

我使用 IDataErrorInfo 接口进行验证,但我已根据需要对其进行了自定义.我想你会发现它也解决了你的一些问题.与您的问题的一个区别是我在我的基本数据类型类中实现了它.

正如您所指出的,这个接口一次只处理一个属性,但显然在这个时代,这不好.所以我只是添加了一个集合属性来代替:

protected ObservableCollection错误 = 新的 ObservableCollection();公共虚拟 ObservableCollection错误{得到 { 返回错误;}}

为了解决您无法显示外部错误的问题(在您的情况下来自视图,但在我的情况下来自视图模型),我只是添加了另一个集合属性:

protected ObservableCollectionexternalErrors = new ObservableCollection();公共 ObservableCollection;外部错误{得到 { 返回外部错误;}}

我有一个 HasError 属性来查看我的集合:

public virtual bool HasError{get { 返回错误 != null &&Errors.Count >0;}}

这使我能够使用自定义 BoolToVisibilityConverter 将其绑定到 Grid.Visibility,例如.显示一个 Grid,里面有一个集合控件,当有错误时会显示错误.它还允许我将 Brush 更改为 Red 以突出显示错误(使用另一个 Converter),但我想你明白了.>

然后在每个数据类型或模型类中,我覆盖 Errors 属性并实现 Item 索引器(在此示例中简化):

public override ObservableCollection错误{得到{错误 = 新的 ObservableCollection();errors.AddUniqueIfNotEmpty(this["Name"]);errors.AddUniqueIfNotEmpty(this["EmailAddresses"]);errors.AddUniqueIfNotEmpty(this["SomeOtherProperty"]);errors.AddRange(ExternalErrors);返回错误;}}公共覆盖字符串 this[string propertyName]{得到{字符串错误 = string.Empty;if (propertyName == "Name" && Name.IsNullOrEmpty()) error = "您必须输入名称字段.";else if (propertyName == "EmailAddresses" && EmailAddresses.Count == 0) error = "您必须在电子邮件地址字段中输入至少一个电子邮件地址.";else if (propertyName == "SomeOtherProperty" && SomeOtherProperty.IsNullOrEmpty()) error = "您必须输入 SomeOtherProperty 字段.";返回错误;}}

AddUniqueIfNotEmpty 方法是一个自定义的 extension 方法,并且按照罐头上说的去做".请注意它将如何依次调用我想要验证的每个属性并从中编译一个集合,而忽略重复的错误.

使用 ExternalErrors 集合,我可以验证无法在数据类中验证的内容:

private void ValidateUniqueName(流派类型){string errorMessage = "流派名称必须是唯一的";if (!IsGenreNameUnique(genre)){if (!genre.ExternalErrors.Contains(errorMessage)) Genre.ExternalErrors.Add(errorMessage);}其他流派.ExternalErrors.Remove(errorMessage);}

为了解决您关于用户在 int 字段中输入字母字符的情况的观点,我倾向于为 TextBox 使用自定义的 IsNumeric AttachedProperty,例如.我不会让他们犯这种错误.我总觉得与其让它发生然后再修复它,不如阻止它.

总的来说,我对自己在 WPF 中的验证能力感到非常满意,一点也不想要.

为了完整起见,我觉得我应该提醒您注意,现在有一个 INotifyDataErrorInfo 接口,其中包含一些附加功能.您可以从 INotifyDataErrorInfo Interface 中了解更多信息MSDN 上的页面.

<小时>

更新>>>

是的,ExternalErrors 属性只是让我从该对象外部添加与数据对象相关的错误...抱歉,我的示例不完整...如果我已经显示如果您使用 IsGenreNameUnique 方法,您会看到它在 Genre 数据项的所有集合以确定对象的名称是否唯一:

private bool IsGenreNameUnique(流派类型){return Genres.Where(d => d.Name != string.Empty && d.Name == Genres.Name).Count() == 1;}

至于您的 int/string 问题,我能看到您在数据类中遇到 那些 错误的唯一方法是,如果您声明你所有的属性都作为 object,但是你会有很多转换要做.也许你可以像这样将你的属性加倍:

public object FooObject { get;放;}//实现 INotifyPropertyChanged公共整数 Foo{get { return FooObject.GetType() == typeof(int) ?int.Parse(FooObject) : -1;}}

然后如果在代码中使用了 Foo 并且在 Binding 中使用了 FooObject,你可以这样做:

public override string this[string propertyName]{得到{字符串错误 = string.Empty;if (propertyName == "FooObject" && FooObject.GetType() != typeof(int))error = "请为 Foo 字段输入一个整数.";...返回错误;}}

这样您就可以满足您的要求,但您将需要添加大量额外代码.

Warning: Very long and detailed post.

Okay, validation in WPF when using MVVM. I’ve read many things now, looked at many SO questions, and tried many approaches, but everything feels somewhat hacky at some point and I’m really not sure how to do it the right way™.

Ideally, I want to have all validation happen in the view model using IDataErrorInfo; so that’s what I did. There are however different aspects that make this solution be not a complete solution for the whole validation topic.

The situation

Let’s take the following simple form. As you can see, it’s nothing fancy. We just have a two textboxes which bind to a string and int property in the view model each. Furthermore we have a button that is bound to an ICommand.

So for the validation we now have a two choices:

  1. We can run the validation automatically whenever the value of a text box changes. As such the user gets an instant response when he entered something invalid.
    • We can take this one step further to disable the button when there are any errors.
  2. Or we can run the validation only explicitly when the button is pressed, then showing all errors if applicable. Obviously we can’t disable the button on errors here.

Ideally, I want to implement choice 1. For normal data bindings with activated ValidatesOnDataErrors this is default behavior. So when the text changes, the binding updates the source and triggers the IDataErrorInfo validation for that property; errors are reported back the view. So far so good.

Validation status in the view model

The interesting bit is to let the view model, or the button in this case, know if there are any errors. The way IDataErrorInfo works, it is mainly there to report errors back to the view. So the view can easily see if there are any errors, display them and even show annotations using Validation.Errors. Furthermore, validation always happens looking at a single property.

So having the view model know when there are any errors, or if the validation succeeded, is tricky. A common solution is to simply trigger the IDataErrorInfo validation for all properties in the view model itself. This is often done using a separate IsValid property. The benefit is that this can also be easily used for disabling the command. The drawback is that this might run the validation on all properties a bit too often, but most validations should be simply enough to not hurt the performance. Another solution would be to remember which properties produced errors using the validation and only check those, but that seems a bit overcomplicated and unnecessary for most times.

The bottom line is that this could work fine. IDataErrorInfo provides the validation for all properties, and we can simply use that interface in the view model itself to run the validation there too for the whole object. Introducing the problem:

Binding exceptions

The view model uses actual types for its properties. So in our example, the integer property is an actual int. The text box used in the view however natively only supports text. So when binding to the int in the view model, the data binding engine will automatically perform type conversions—or at least it will try. If you can enter text in a text box meant for numbers, the chances are high that there won’t always be valid numbers inside: So the data binding engine will fail to convert and throw a FormatException.

On the view side, we can easily see that. Exceptions from the binding engine are automatically caught by WPF and are displayed as errors—there isn’t even a need to enable Binding.ValidatesOnExceptions which would be required for exceptions thrown in the setter. The error messages do have a generic text though, so that could be a problem. I have solved this for myself by using a Binding.UpdateSourceExceptionFilter handler, inspecting the exception being thrown and looking at the source property and then generating a less generic error message instead. All that capsulated away into my own Binding markup extension, so I can have all the defaults I need.

So the view is fine. The user makes an error, sees some error feedback and can correct it. The view model however is lost. As the binding engine threw the exception, the source was never updated. So the view model is still on the old value, which isn’t what’s being displayed to the user, and the IDataErrorInfo validation obviously doesn’t apply.

What’s worse, there is no good way for the view model to know this. At least, I haven’t found a good solution for this yet. What would be possible is to have the view report back to the view model that there was an error. This could be done by data binding the Validation.HasError property back to the view model (which isn’t possible directly), so the view model could check the view’s state first.

Another option would be to relay the exception handled in Binding.UpdateSourceExceptionFilter to the view model, so it would be notified of it as well. The view model could even provide some interface for the binding to report these things, allowing for custom error messages instead of generic per-type ones. But that would create a stronger coupling from the view to the view model, which I generally want to avoid.

Another "solution" would be to get rid of all typed properties, use plain string properties and do the conversion in the view model instead. This obviously would move all validation to the view model, but also mean an incredible amount of duplication of things the data binding engine usually takes care of. Furthermore it would change the semantics of the view model. For me, a view is built for the view model and not the reverse—of course the design of the view model depends on what we imagine the view to do, but there’s still general freedom how the view does that. So the view model defines an int property because there is a number; the view can now use a text box (allowing all these problems), or use something that natively works with numbers. So no, changing the types of the properties to string is not an option for me.

In the end, this is a problem of the view. The view (and its data binding engine) is responsible for giving the view model proper values to work with. But in this case, there seems to be no good way to tell the view model that it should invalidate the old property value.

BindingGroups

Binding groups are one way I tried to tackle this. Binding groups have the ability to group all validations, including IDataErrorInfo and thrown exceptions. If available to the view model, they even have a mean to check the validation status for all of those validation sources, for example using CommitEdit.

By default, binding groups implement choice 2 from above. They make the bindings update explicitly, essentially adding an additional uncommitted state. So when clicking the button, the command can commit those changes, trigger the source updates and all validations and get a single result if it succeeded. So the command’s action could be this:

 if (bindingGroup.CommitEdit())
     SaveEverything();

CommitEdit will only return true if all validations succeeded. It will take IDataErrorInfo into account and also check binding exceptions. This seems to be a perfect solution for choice 2. The only thing that is a bit of a hassle is managing the binding group with the bindings, but I’ve built myself something that mostly takes care of this (related).

If a binding group is present for a binding, the binding will default to an explicit UpdateSourceTrigger. To implement choice 1 from above using binding groups, we basically have to change the trigger. As I have a custom binding extension anyway, this is rather simple, I just set it to LostFocus for all.

So now, the bindings will still update whenever a text field changes. If the source could be updated (binding engine throws no exception) then IDataErrorInfo will run as usual. If it couldn’t be updated the view is still able to see it. And if we click our button, the underlying command can call CommitEdit (although nothing needs to be committed) and get the total validation result to see if it can continue.

We might not be able to disable the button easily this way. At least not from the view model. Checking the validation over and over is not really a good idea just to update the command status, and the view model isn’t notified when a binding engine exception is thrown anyway (which should disable the button then)—or when it goes away to enable the button again. We could still add a trigger to disable the button in the view using the Validation.HasError so it’s not impossible.

Solution?

So overall, this seems to be the perfect solution. What is my problem with it though? To be honest, I’m not entirely sure. Binding groups are a complex thing that seem to be usually used in smaller groups, possibly having multiple binding groups in a single view. By using one big binding group for the whole view just to ensure my validation, it feels as if I’m abusing it. And I just keep thinking, that there must be a better way to solve this whole situation, because surely I can’t be the only one having these problems. And so far I haven’t really seen many people use binding groups for validation with MVVM at all, so it just feels odd.

So, what exactly is the proper way to do validation in WPF with MVVM while being able to check for binding engine exceptions?


My solution (/hack)

First of all, thanks for your input! As I have written above, I’m using IDataErrorInfo already to do my data validation and I personally believe it’s the most comfortable utility to do the validation job. I’m using utilities similar to what Sheridan suggested in his answer below, so maintaining works fine too.

In the end, my problem boiled down to the binding exception issue, where the view model just wouldn’t know about when it happened. While I could handle this with binding groups as detailed above, I still decided against it, as I just didn’t feel all that comfortable with it. So what did I do instead?

As I mentioned above, I detect binding exceptions on the view-side by listening to a binding’s UpdateSourceExceptionFilter. In there, I can get a reference to the view model from the binding expression’s DataItem. I then have an interface IReceivesBindingErrorInformation which registers the view model as a possible receiver for information about binding errors. I then use that to pass the binding path and the exception to the view model:

object OnUpdateSourceExceptionFilter(object bindExpression, Exception exception)
{
    BindingExpression expr = (bindExpression as BindingExpression);
    if (expr.DataItem is IReceivesBindingErrorInformation)
    {
        ((IReceivesBindingErrorInformation)expr.DataItem).ReceiveBindingErrorInformation(expr.ParentBinding.Path.Path, exception);
    }

    // check for FormatException and produce a nicer error
    // ...
 }

In the view model I then remember whenever I am notified about a path’s binding expression:

HashSet<string> bindingErrors = new HashSet<string>();
void IReceivesBindingErrorInformation.ReceiveBindingErrorInformation(string path, Exception exception)
{
    bindingErrors.Add(path);
}

And whenever the IDataErrorInfo revalidates a property, I know that the binding worked, and I can clear the property from the hash set.

In the view model I then can check if the hash set contains any items and abort any action that requires the data to be validated completely. It might not be the nicest solution due to the coupling from the view to the view model, but using that interface it’s at least somewhat less a problem.

解决方案

Warning: Long answer also

I use the IDataErrorInfo interface for validation, but I have customised it to my needs. I think that you'll find that it solves some of your problems too. One difference to your question is that I implement it in my base data type class.

As you pointed out, this interface just deals with one property at a time, but clearly in this day and age, that's no good. So I just added a collection property to use instead:

protected ObservableCollection<string> errors = new ObservableCollection<string>();

public virtual ObservableCollection<string> Errors
{
    get { return errors; }
}

To address your problem of not being able to display external errors (in your case from the view, but in mine from the view model), I simply added another collection property:

protected ObservableCollection<string> externalErrors = new ObservableCollection<string>();

public ObservableCollection<string> ExternalErrors
{
    get { return externalErrors; }
}

I have an HasError property which looks at my collection:

public virtual bool HasError
{
    get { return Errors != null && Errors.Count > 0; }
}

This enables me to bind this to Grid.Visibility using a custom BoolToVisibilityConverter, eg. to show a Grid with a collection control inside that shows the errors when there are any. It also lets me change a Brush to Red to highlight an error (using another Converter), but I guess you get the idea.

Then in each data type, or model class, I override the Errors property and implement the Item indexer (simplified in this example):

public override ObservableCollection<string> Errors
{
    get
    {
        errors = new ObservableCollection<string>();
        errors.AddUniqueIfNotEmpty(this["Name"]);
        errors.AddUniqueIfNotEmpty(this["EmailAddresses"]);
        errors.AddUniqueIfNotEmpty(this["SomeOtherProperty"]);
        errors.AddRange(ExternalErrors);
        return errors;
    }
}

public override string this[string propertyName]
{
    get
    {
        string error = string.Empty;
        if (propertyName == "Name" && Name.IsNullOrEmpty()) error = "You must enter the Name field.";
        else if (propertyName == "EmailAddresses" && EmailAddresses.Count == 0) error = "You must enter at least one e-mail address into the Email address(es) field.";
        else if (propertyName == "SomeOtherProperty" && SomeOtherProperty.IsNullOrEmpty()) error = "You must enter the SomeOtherProperty field.";
        return error;
    }
}

The AddUniqueIfNotEmpty method is a custom extension method and 'does what is says on the tin'. Note how it will call each property that I want to validate in turn and compile a collection from them, ignoring duplicate errors.

Using the ExternalErrors collection, I can validate things that I can't validate in the data class:

private void ValidateUniqueName(Genre genre)
{
    string errorMessage = "The genre name must be unique";
    if (!IsGenreNameUnique(genre))
    {
        if (!genre.ExternalErrors.Contains(errorMessage)) genre.ExternalErrors.Add(errorMessage);
    }
    else genre.ExternalErrors.Remove(errorMessage);
}

To address your point regarding the situation where a user enters an alphabetical character into a int field, I tend to use a custom IsNumeric AttachedProperty for the TextBox, eg. I don't let them make these kinds of errors. I always feel that it's better to stop it, than to let it happen and then fix it.

Overall I'm really happy with my validation ability in WPF and am not left wanting at all.

To end with and for completeness, I felt that I should alert you to the fact that there is now an INotifyDataErrorInfo interface which includes some of this added functionality. You can find out more from the INotifyDataErrorInfo Interface page on MSDN.


UPDATE >>>

Yes, the ExternalErrors property just let's me add errors that relate to a data object from outside that object... sorry, my example wasn't complete... if I'd have shown you the IsGenreNameUnique method, you would have seen that it uses LinQ on all of the Genre data items in the collection to determine whether the object's name is unique or not:

private bool IsGenreNameUnique(Genre genre)
{
    return Genres.Where(d => d.Name != string.Empty && d.Name == genre.Name).Count() == 1;
}

As for your int/string problem, the only way I can see you getting those errors in your data class is if you declare all your properties as object, but then you'd have an awful lot of casting to do. Perhaps you could double your properties like this:

public object FooObject { get; set; } // Implement INotifyPropertyChanged

public int Foo
{
    get { return FooObject.GetType() == typeof(int) ? int.Parse(FooObject) : -1; }
}

Then if Foo was used in code and FooObject was used in the Binding, you could do this:

public override string this[string propertyName]
{
    get
    {
        string error = string.Empty;
        if (propertyName == "FooObject" && FooObject.GetType() != typeof(int)) 
            error = "Please enter a whole number for the Foo field.";
        ...
        return error;
    }
}

That way you could fulfil your requirements, but you'll have a lot of extra code to add.

这篇关于使用 MVVM 进行正确验证的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆