WPF之修改变色,回滚功能的实现

时隔多年,居然会写一篇关于.NET的文章, 而且还是关于WPF的。真是意外!

这里我就不给出相应的demo了;如果有缘人恰好有这个需求并且自己无法成功,就麻烦到时候再私信我吧。

1. 起因

最近在给公司写内部CS程序时,碰到这样一个需求——进行TextBox内容或者ComboBox的内容编辑时,我如何知道该输入/选择框的值被我修改过?另外一个与此紧密相关的就是编辑器里常见的修改回滚功能。因为关系紧密,所以本博客将两者一起进行讲解,节省篇幅,也节省各位看官的时间。

其实这个需求在我做专职WPF开发时就遇到过,当时的解决方案是使用.NET的TypeDescriptionProvider注解来为VM提供额外的属性。当时因为水平有限,觉得它比较复杂;加之这么多年了也完全忘记了其中的细节(根据这些年的感悟,之前的水平低可以接受,这笔记没做好就不能忍了。我那逝去的年华,唉!)。所以这次干脆换了种实现方式。得益于从事Java之后的这么几年里源码的学习,这次的实现过程中感觉顺手了不少。

2. 效果

界面展示效果就不要吐槽了,我们的关注点是功能。

  1. 开始编辑前
    开始编辑前
  2. 编辑后
    编辑后
  3. 复原
    复原

3. 使用方法

为了将此功能通用,所以这次选择使用WPF里的附加属性的方式。

3.1 XAML文件
<!-- XAML中 -->
<ComboBox Grid.Row="2" Grid.Column="1" ItemsSource="{Binding SqlTypeList}"  SelectedValue="{Binding SqlType}" behavior:TransactionBehavior.CanRollback="True"/>

<TextBox Grid.Row="0" Grid.Column="1"  Text="{Binding Name}" behavior:TransactionBehavior.CanRollback="True"/>
3.2 相应的VM
// 对应的VM
public sealed class ActionTreeNodeViewModel : ObservableObject, IFeedback{
        private void instantiateCommand()
        {
            // 回滚变化
            ResetActionRecordCommand = new RelayCommand<ActionTreeNodeViewModel>((currentAtnVm) =>
            {
                App.Messenger.NotifyColleagues(MessengerToken.TRANSACTION_ROLLBACK, currentAtnVm);
            }, (currentAtnVm) =>
            {
                return true;
            });

            // 保存编辑的结果
            SaveActionRecordCommand = new RelayCommand<ActionTreeNodeViewModel>((currentAtnVm) =>
            {
                // 去某处获取action原始的name, service原始的id
                App.Messenger.NotifyColleagues(MessengerToken.TRANSACTION_RETRIEVAL, currentAtnVm);
                App.Messenger.NotifyColleagues(MessengerToken.TRANSACTION_RETRIEVAL, currentAtnVm.Parent);

                ServiceFileOperater.SaveActionNode(currentAtnVm, currentAtnVm.Parent.LocationFile.FullPath);
                App.Messenger.NotifyColleagues(MessengerToken.TRANSACTION_APPLY, currentAtnVm);
            }, (currentAtnVm) =>
            {
                return true;
            });
        }

        // Interface implement
        void IFeedback.Feedback(Dictionary<string, Tuple<object, object>> modifyVals, Object dataContext)
        {
            if (dataContext != this)
            {
                return;
            }

            bool anyChange = false;
            foreach (KeyValuePair<String, Tuple<object, object>> keyVal in modifyVals)
            {
                var oldVal = keyVal.Value.Item1;
                var newVal = keyVal.Value.Item2;
                if (oldVal is IComparable)
                {
                    if (((IComparable)oldVal).CompareTo(newVal) != 0)
                    {
                        anyChange = true;
                        break;
                    }

                }
            }

            // 本vm的值被修改过
            this.IsModified = anyChange;
        }


        void IFeedback.RetrievalChange(Dictionary<string, Tuple<object, object>> modifyVals, Object dataContext)
        {
            if (dataContext != this)
            {
                return;
            }

            // 我们需要原始name去更新文件
            var nameStr = ReflectHelper.GetMemberName((ActionTreeNodeViewModel c) => c.Name);
            if (!modifyVals.ContainsKey(nameStr))
            {
                return;
            }

            var originValOfName = modifyVals[nameStr].Item1;

            this.OriginName = originValOfName.ToString();
        }
}

4. 相关源码

4.1 IFeedback 接口
// 实现这个接口代表需要反馈相关信息
interface IFeedback
{
    /// <summary>
    /// 所有希望得到被改变相关信息的对象都应该实现这个接口
    /// </summary>
    /// <param name="modifyVals">被改变的字段名, 和改变前的值,改变后的值</param>
    /// <param name="dataContext">当前dataContext</param>
    void Feedback(Dictionary<String, Tuple<Object, Object>> modifyVals, Object dataContext);

    /// <summary>
    /// 检索变化, 为了尽量减少TransactionBehavior的关联性, 故增加该方法; 如果有需要检索变化情况来进行某些操作时,实现该方法; 
    ///  例如我们需要Service, Action的原始唯一性标识去更新文件; 为了不让TransactionBehavior知晓Service,Action的存在.
    ///   1. 通过实现该接口, service,action通过发送message来进行orgin主键的更新
    /// </summary>
    /// <param name="modifyVals">被改变的字段名, 和改变前的值,改变后的值</param>
    /// <param name="dataContext">当前dataContext</param>
    void RetrievalChange(Dictionary<String, Tuple<Object, Object>> modifyVals,Object dataContext);
}
4.2 TransactionBehavior
    // FIXME 这里面有大量 is TextBox类似的判断 !!!
    // 可回滚
    public sealed class TransactionBehavior
    {
        // 控件 - 对应的DataContext 组成的键值对
        // 所以以Value反查key的话, 我们就能找到以该Value为DataContext的控件
        private static Dictionary<Control, Object> controlDataContextDic = new Dictionary<Control, object>();

        #region DP
        // 原始值
        private static DependencyProperty OriginValProperty = DependencyProperty.Register("OriginVal", typeof(Object), typeof(Control), new PropertyMetadata(String.Empty, (deo, args) =>
        {
            // DependencyObject d, DependencyPropertyChangedEventArgs e

        }));
        // 是否被编辑过?
        private static DependencyProperty IsEditProperty = DependencyProperty.Register("IsEdit", typeof(bool), typeof(Control), new PropertyMetadata(false, (deo, args) =>
        {
            // DependencyObject d, DependencyPropertyChangedEventArgs e

        }));
        #endregion

        static TransactionBehavior()
        {
            //
            // 应用
            App.Messenger.Register<Object>(MessengerToken.TRANSACTION_APPLY, (source) =>
            {
                foreach (KeyValuePair<Control, Object> keyVal in controlDataContextDic)
                {
                    // 更新每个控件自身记录的OriginVal
                    if (keyVal.Value == source)
                    {
                        var currentControl = keyVal.Key;
                        DependencyProperty dp = getDP(currentControl);
                        var name = currentControl.GetBindingExpression(dp).ParentBinding.Path.Path;
                        var val = source.GetType().GetProperty(name).GetValue(source, null);

                        currentControl.SetValue(OriginValProperty, val);
                        currentControl.Background = System.Windows.Media.Brushes.White;
                    }
                }

                checkIfItemOfDataContextChanged(source);
            });

            // 回滚
            App.Messenger.Register<Object>(MessengerToken.TRANSACTION_ROLLBACK, (source) =>
            {
                foreach (KeyValuePair<Control, Object> keyVal in controlDataContextDic)
                {
                    // 回滚每个控件自身记录的OriginVal
                    if (keyVal.Value == source)
                    {
                        var currentControl = keyVal.Key;
                        setControlValue(currentControl, currentControl.GetValue(OriginValProperty));
                    }
                }

                checkIfItemOfDataContextChanged(source);
            });

            // 检索原始值
            App.Messenger.Register<Object>(MessengerToken.TRANSACTION_RETRIEVAL, (source) =>
            {
                if (!(source is IFeedback))
                {
                    return;
                }

                var context = retrievalChanged(source);
                ((IFeedback)source).RetrievalChange(context, source);
            });
        }

        public static bool GetCanRollback(DependencyObject obj)
        {
            return (bool)obj.GetValue(CanRollbackProperty);
        }

        public static void SetCanRollback(DependencyObject obj, bool value)
        {
            obj.SetValue(CanRollbackProperty, value);
        }

        // Using a DependencyProperty as the backing store for CanRollback.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty CanRollbackProperty =
            DependencyProperty.RegisterAttached("CanRollback", typeof(bool), typeof(TransactionBehavior), new PropertyMetadata(false, OnValueChanged));


        private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
        {
            // 设置本控件最初始的值
            setOriginVal(d);

            // 挂接值改变时的事件
            onValChangedEvent(d);

            // 记录控件及其DataContext的对应关系
            controlDataContextDic.Add((Control)d, ((Control)d).DataContext);

            onDataContextChanged(d);

            //((SimpleDO)d).OnValueChanged(e);
        }

        private static void setOriginVal(DependencyObject d)
        {
            System.Windows.Data.BindingExpression be = ((Control)d).GetBindingExpression(getDP(((Control)d)));

            if (null == be)
            {
                return;
            }

            // 设置初始值
            var binding = be.ParentBinding;

            // source为 datacontext
            // binding.Source 这个Source就是binding里设置的哪个
            var source = be.DataItem;

            var path = binding.Path;
            var pi = source.GetType().GetProperty(path.Path);
            //if (null == pi)
            //{
            //    d.SetValue(OriginValProperty, "");
            //}
            //else
            //{
            // https://stackoverflow.com/questions/3531318/convert-changetype-fails-on-nullable-types
            var pType = pi.PropertyType;
            pType = Nullable.GetUnderlyingType(pType) ?? pType;
            var pVal = pi.GetValue(source, null);
            var value = pVal == null ? null : Convert.ChangeType(pVal, pType);
            d.SetValue(OriginValProperty, value);
            //}


        }

        private static void onValChangedEvent(DependencyObject d)
        {

            if (d is TextBox)
            {
                ((TextBox)d).TextChanged += (sender, args) =>
                {
                    // object sender, TextChangedEventArgs e
                    // var sou = args.Source; // 一般情况下等于sender
                    // 给出该控件内容已被修改
                    var tbCopy = ((TextBox)sender);
                    var changes = args.Changes;
                    var currentTxt = tbCopy.Text;

                    var originVal = ((TextBox)sender).GetValue(OriginValProperty);
                    if (originVal != null && currentTxt.Equals(originVal.ToString()))
                    {
                        tbCopy.SetValue(IsEditProperty, false);
                        tbCopy.Background = System.Windows.Media.Brushes.White;
                    }
                    else
                    {
                        tbCopy.SetValue(IsEditProperty, true);
                        tbCopy.Background = System.Windows.Media.Brushes.Red;
                    }

                    checkIfItemOfDataContextChanged(tbCopy.DataContext);
                };
            }
            else if (d is ComboBox)
            {
                ((ComboBox)d).SelectionChanged += (sender, args) =>
                {
                    // object sender, SelectionChangedEventArgs e
                    // var sou = args.Source; // 一般情况下等于sender
                    // 提示被修改
                    //var changes = args.
                    var cbCopy = ((ComboBox)sender);
                    var currentVal = cbCopy.SelectedItem;//SelectedValue取到的还是之前的值,而不是被回滚操作设置的值

                    var originVal = cbCopy.GetValue(OriginValProperty);
                    if (originVal != null && currentVal != null && currentVal.ToString().Equals(originVal.ToString()))
                    {
                        cbCopy.SetValue(IsEditProperty, false);
                        cbCopy.Background = System.Windows.Media.Brushes.White;
                    }
                    else
                    {
                        cbCopy.SetValue(IsEditProperty, true);
                        cbCopy.Background = System.Windows.Media.Brushes.Red;
                    }

                    checkIfItemOfDataContextChanged(cbCopy.DataContext);
                };
            }
        }

        private static void onDataContextChanged(DependencyObject d)
        {
            ((Control)d).DataContextChanged += (sender, args) =>
            {
                // object sender, DependencyPropertyChangedEventArgs e

                // 确认之前的记录正确
                if (controlDataContextDic[(Control)sender] != args.OldValue)
                {
                    throw new ArgumentException("之前的DataContext记录混乱!记录的为: [ " + controlDataContextDic[(Control)sender] + "]; 事件回调传递的是: [ " + args.OldValue + " ]");
                }
                //更新为 控件更换的DataContext
                controlDataContextDic.Add((Control)sender, args.NewValue);

                // ------ 修改OriginVal
                System.Windows.Data.BindingExpression beCopy = ((Control)d).GetBindingExpression(getDP(((Control)d)));

                if (null == beCopy)
                {
                    return;
                }

                var bindingCopy = beCopy.ParentBinding;
                var pathCopy = bindingCopy.Path;
                var piCopy = args.NewValue.GetType().GetProperty(pathCopy.Path);
                var valueCopy = piCopy.GetValue(args.NewValue, null);
                ((Control)sender).SetValue(OriginValProperty, valueCopy);
            };

        }

        private static void setControlValue(Control currentControl, Object val)
        {
            DependencyProperty dp = getDP(currentControl);
            currentControl.SetValue(dp, val);

        }

        private static void checkIfItemOfDataContextChanged(Object dataContext)
        {
            if (!(dataContext is IFeedback))
            {
                return;
            }

            var context = retrievalChanged(dataContext);

            ((IFeedback)dataContext).Feedback(context, dataContext);
        }

        private static Dictionary<String, Tuple<Object, Object>> retrievalChanged(Object dataContext)
        {

            Dictionary<String, Tuple<Object, Object>> context = new Dictionary<String, Tuple<Object, Object>>();
            foreach (KeyValuePair<Control, Object> keyVal in controlDataContextDic)
            {
                // 以该dataContext为数据源的控件
                if (keyVal.Value == dataContext)
                {
                    var currentControl = keyVal.Key;
                    DependencyProperty dp = getDP(currentControl);

                    String name = currentControl.GetBindingExpression(dp).ParentBinding.Path.Path;
                    Object currentVal = currentVal = currentControl.GetValue(dp);
                    Object originVal = currentControl.GetValue(OriginValProperty);

                    if (context.ContainsKey(name))
                    {
                        context.Remove(name);
                    }
                    context.Add(name, Tuple.Create<Object, Object>(currentControl.GetValue(OriginValProperty), currentVal));
                }
            }

            return context;
        }

        private static DependencyProperty getDP(Control currentControl)
        {
            DependencyProperty dp = null;
            if (currentControl is TextBox)
            {
                dp = TextBox.TextProperty;

            }
            else if (currentControl is ComboBox)
            {
                dp = ComboBox.SelectedValueProperty;
            }

            return dp;
        }
    }
}
4.3 Messenger组件

本解决方案使用了MVVMLight里的Messenger组件;除此之外就没有其它额外的依赖了。对此也在这里一并给出该组件相应的源码。

/// <summary>
/// Provides loosely-coupled messaging between
/// various colleague objects.  All references to objects
/// are stored weakly, to prevent memory leaks.
/// </summary>
public class Messenger
{
    #region Constructor

    public Messenger()
    {
    }

    #endregion // Constructor

    #region Register

    /// <summary>
    /// Registers a callback method, with no parameter, to be invoked when a specific message is broadcasted.
    /// </summary>
    /// <param name="message">The message to register for.</param>
    /// <param name="callback">The callback to be called when this message is broadcasted.</param>
    public void Register(string message, Action callback)
    {
        this.Register(message, callback, null);
    }

    /// <summary>
    /// Registers a callback method, with a parameter, to be invoked when a specific message is broadcasted.
    /// </summary>
    /// <param name="message">The message to register for.</param>
    /// <param name="callback">The callback to be called when this message is broadcasted.</param>
    public void Register<T>(string message, Action<T> callback)
    {
        this.Register(message, callback, typeof(T));
    }

    void Register(string message, Delegate callback, Type parameterType)
    {
        if (String.IsNullOrEmpty(message))
            throw new ArgumentException("'message' cannot be null or empty.");

        if (callback == null)
            throw new ArgumentNullException("callback");

        this.VerifyParameterType(message, parameterType);

        _messageToActionsMap.AddAction(message, callback.Target, callback.Method, parameterType);
    }

    [Conditional("DEBUG")]
    void VerifyParameterType(string message, Type parameterType)
    {
        Type previouslyRegisteredParameterType = null;
        if (_messageToActionsMap.TryGetParameterType(message, out previouslyRegisteredParameterType))
        {
            if (previouslyRegisteredParameterType != null && parameterType != null)
            {
                if (!previouslyRegisteredParameterType.Equals(parameterType))
                    throw new InvalidOperationException(string.Format(
                        "The registered action's parameter type is inconsistent with the previously registered actions for message '{0}'.\nExpected: {1}\nAdding: {2}",
                        message,
                        previouslyRegisteredParameterType.FullName,
                        parameterType.FullName));
            }
            else
            {
                // One, or both, of previouslyRegisteredParameterType or callbackParameterType are null.
                if (previouslyRegisteredParameterType != parameterType)   // not both null?
                {
                    throw new TargetParameterCountException(string.Format(
                        "The registered action has a number of parameters inconsistent with the previously registered actions for message \"{0}\".\nExpected: {1}\nAdding: {2}",
                        message,
                        previouslyRegisteredParameterType == null ? 0 : 1,
                        parameterType == null ? 0 : 1));
                }
            }
        }
    }

    #endregion // Register

    #region NotifyColleagues

    /// <summary>
    /// Notifies all registered parties that a message is being broadcasted.
    /// </summary>
    /// <param name="message">The message to broadcast.</param>
    /// <param name="parameter">The parameter to pass together with the message.</param>
    public void NotifyColleagues(string message, object parameter)
    {
        if (String.IsNullOrEmpty(message))
            throw new ArgumentException("'message' cannot be null or empty.");

        Type registeredParameterType;
        if (_messageToActionsMap.TryGetParameterType(message, out registeredParameterType))
        {
            if (registeredParameterType == null)
                throw new TargetParameterCountException(string.Format("Cannot pass a parameter with message '{0}'. Registered action(s) expect no parameter.", message));
        }

        var actions = _messageToActionsMap.GetActions(message);
        if (actions != null)
            actions.ForEach(action => action.DynamicInvoke(parameter));
    }

    /// <summary>
    /// Notifies all registered parties that a message is being broadcasted.
    /// </summary>
    /// <param name="message">The message to broadcast.</param>
    public void NotifyColleagues(string message)
    {
        if (String.IsNullOrEmpty(message))
            throw new ArgumentException("'message' cannot be null or empty.");

        Type registeredParameterType;
        if (_messageToActionsMap.TryGetParameterType(message, out registeredParameterType))
        {
            if (registeredParameterType != null)
                throw new TargetParameterCountException(string.Format("Must pass a parameter of type {0} with this message. Registered action(s) expect it.", registeredParameterType.FullName));
        }

        var actions = _messageToActionsMap.GetActions(message);
        if (actions != null)
            actions.ForEach(action => action.DynamicInvoke());
    }

    #endregion // NotifyColleauges

    #region MessageToActionsMap [nested class]

    /// <summary>
    /// This class is an implementation detail of the Messenger class.
    /// </summary>
    private class MessageToActionsMap
    {
        #region Constructor

        internal MessageToActionsMap()
        {
        }

        #endregion // Constructor

        #region AddAction

        /// <summary>
        /// Adds an action to the list.
        /// </summary>
        /// <param name="message">The message to register.</param>
        /// <param name="target">The target object to invoke, or null.</param>
        /// <param name="method">The method to invoke.</param>
        /// <param name="actionType">The type of the Action delegate.</param>
        internal void AddAction(string message, object target, MethodInfo method, Type actionType)
        {
            if (message == null)
                throw new ArgumentNullException("message");

            if (method == null)
                throw new ArgumentNullException("method");

            lock (_map)
            {
                if (!_map.ContainsKey(message))
                    _map[message] = new List<WeakAction>();

                _map[message].Add(new WeakAction(target, method, actionType));
            }
        }

        #endregion // AddAction

        #region GetActions

        /// <summary>
        /// Gets the list of actions to be invoked for the specified message
        /// </summary>
        /// <param name="message">The message to get the actions for</param>
        /// <returns>Returns a list of actions that are registered to the specified message</returns>
        internal List<Delegate> GetActions(string message)
        {
            if (message == null)
                throw new ArgumentNullException("message");

            List<Delegate> actions;
            lock (_map)
            {
                if (!_map.ContainsKey(message))
                    return null;

                List<WeakAction> weakActions = _map[message];
                actions = new List<Delegate>(weakActions.Count);
                for (int i = weakActions.Count - 1; i > -1; --i)
                {
                    WeakAction weakAction = weakActions[i];
                    if (weakAction == null)
                        continue;

                    Delegate action = weakAction.CreateAction();
                    if (action != null)
                    {
                        actions.Add(action);
                    }
                    else
                    {
                        // The target object is dead, so get rid of the weak action.
                        weakActions.Remove(weakAction);
                    }
                }

                // Delete the list from the map if it is now empty.
                if (weakActions.Count == 0)
                    _map.Remove(message);
            }

            // Reverse the list to ensure the callbacks are invoked in the order they were registered.
            actions.Reverse();

            return actions;
        }

        #endregion // GetActions

        #region TryGetParameterType

        /// <summary>
        /// Get the parameter type of the actions registered for the specified message.
        /// </summary>
        /// <param name="message">The message to check for actions.</param>
        /// <param name="parameterType">
        /// When this method returns, contains the type for parameters 
        /// for the registered actions associated with the specified message, if any; otherwise, null.
        /// This will also be null if the registered actions have no parameters.
        /// This parameter is passed uninitialized.
        /// </param>
        /// <returns>true if any actions were registered for the message</returns>
        internal bool TryGetParameterType(string message, out Type parameterType)
        {
            if (message == null)
                throw new ArgumentNullException("message");

            parameterType = null;
            List<WeakAction> weakActions;
            lock (_map)
            {
                if (!_map.TryGetValue(message, out weakActions) || weakActions.Count == 0)
                    return false;
            }
            parameterType = weakActions[0].ParameterType;
            return true;
        }

        #endregion // TryGetParameterType

        #region Fields

        // Stores a hash where the key is the message and the value is the list of callbacks to invoke.
        readonly Dictionary<string, List<WeakAction>> _map = new Dictionary<string, List<WeakAction>>();

        #endregion // Fields
    }

    #endregion // MessageToActionsMap [nested class]

    #region WeakAction [nested class]

    /// <summary>
    /// This class is an implementation detail of the MessageToActionsMap class.
    /// </summary>
    private class WeakAction
    {
        #region Constructor

        /// <summary>
        /// Constructs a WeakAction.
        /// </summary>
        /// <param name="target">The object on which the target method is invoked, or null if the method is static.</param>
        /// <param name="method">The MethodInfo used to create the Action.</param>
        /// <param name="parameterType">The type of parameter to be passed to the action. Pass null if there is no parameter.</param>
        internal WeakAction(object target, MethodInfo method, Type parameterType)
        {
            if (target == null)
            {
                _targetRef = null;
            }
            else
            {
                _targetRef = new WeakReference(target);
            }

            _method = method;

            this.ParameterType = parameterType;

            if (parameterType == null)
            {
                _delegateType = typeof(Action);
            }
            else
            {
                _delegateType = typeof(Action<>).MakeGenericType(parameterType);
            }
        }

        #endregion // Constructor

        #region CreateAction

        /// <summary>
        /// Creates a "throw away" delegate to invoke the method on the target, or null if the target object is dead.
        /// </summary>
        internal Delegate CreateAction()
        {
            // Rehydrate into a real Action object, so that the method can be invoked.
            if (_targetRef == null)
            {
                return Delegate.CreateDelegate(_delegateType, _method);
            }
            else
            {
                try
                {
                    object target = _targetRef.Target;
                    if (target != null)
                        return Delegate.CreateDelegate(_delegateType, target, _method);
                }
                catch
                {
                }
            }

            return null;
        }

        #endregion // CreateAction

        #region Fields

        internal readonly Type ParameterType;

        readonly Type _delegateType;
        readonly MethodInfo _method;
        readonly WeakReference _targetRef;

        #endregion // Fields
    }

    #endregion // WeakAction [nested class]

    #region Fields

    readonly MessageToActionsMap _messageToActionsMap = new MessageToActionsMap();

    #endregion // Fields
}

4. 总结

转投Java这么久了,但回首时发现在看过Java里看的那些源码之后,之前.NET里那些对于我而言模糊晦涩的概念不知不觉中变得无比清晰。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值