Unity编辑器扩展精粹笔记(更新至第一部分,创建笔记,可选择已完成和未完成)

第一部分,做出如下界面并实现相关功能,添加删除,是否确认完成。
在这里插入图片描述
在这里插入图片描述
实现步骤:
这个功能实现起来很简单,创建一个类,有一个数据的成员(此处用最简单的List)。调用获取界面的API(创建页面的方法),然后在OnGUI方法中(可以先理解为update)根据List调用GUILayout一系列API即可实现(绘制的相关方法)。

//调用窗口API
var window = GetWindow<T>(true);
//按钮API
if (GUILayout.Button(Text))
{
   OnClickEvent.Invoke();
}
//文本输入框API
Text = EditorGUILayout.TextArea(Text);
//单选框API
bool = GUILayout.Toggle(bool, Text);

只要在OnGUI调用上述API即可完成界面的绘制,但是这样不易维护,而且属于面向过程编程,所以对上述内容进行封装一下。

代码规范优化(做一个渲染框架):
先创建一个IView的接口

    public interface IView
    {
        //显示方法  功能有显示完成和未完成,Show和Hide控制
        void Show();
        //隐藏方法
        void Hide();
        //渲染方法
        void DrawGUI();
        void RemoveFromParent();
        ILayout Parent { get; set; }
    }

创建一个抽象类View继承与IView,把需要实现在OnGUI的方法留给子类去实现。

public abstract class View : IView
    {
    	//是否可见,显示完成和未完成功能的判断参数
        public bool Visible { get; set; } = true;
        public void Show()
        {
            Visible = true;
        }
        public void Hide()
        {
            Visible = false;
        }
		//调用渲染的方法
        public void DrawGUI()
        {
            if(Visible)
            {
                OnGUI();
            }
        }
        //渲染
        protected abstract void OnGUI();

        public ILayout Parent { get; set; }

        public void RemoveFromParent()
        {
            Parent.RemoveChild(this);
        }
    }

ButtonView:点击按钮,有一个string参数显示文本以及点击事件

public class ButtonView : View
    {
        public ButtonView(string text,Action onClickEvent)
        {
            Text = text;
            OnClickEvent = onClickEvent;
        }
		//显示的文本
        public string Text { get; set; }
		//触发的点击事件
        public Action OnClickEvent { get; set; }
        protected override void OnGUI()
        {
            if (GUILayout.Button(Text))
            {
                OnClickEvent.Invoke();
            }
        }
    }

CustomView:自定义,只有一个事件,用来添加装饰的代码,例如GUILayout.BeginVertical("box");

    public class CustomView : View
    {
        public CustomView(Action onGUIAction)
        {
            OnGUIAction = onGUIAction;
        }
        public Action OnGUIAction { get; set; }

        protected override void OnGUI()
        {
            OnGUIAction.Invoke();
        }
    }

SpaceView:添加空白处,有一个Int参数以及添加空白的方法写在OnGUI中

    public class SpaceView : View
    {
        public int Pixel { get; set; } = 10;

        public SpaceView(int pixel=10)
        {
            Pixel = pixel;
        }

        protected override void OnGUI()
        {
            GUILayout.Space(Pixel);
        }
    }

TextAreaView:文本输入框,有一个Property的数据工具类,后续会详细介绍

    public class TextAreaView : View
    {
        public TextAreaView(string content)     
        {
            Content = new Property<string>(content);
        }
        public Property<string> Content { get; set; }

        protected override void OnGUI()
        {
            //EditorGUILayout相比GUILayou支持显示中文
            Content.Value = EditorGUILayout.TextArea(Content.Value);
        }
    }

ToggleView:单选框,有一个string参数显示文本,以及Property数据工具类,后续会详细介绍

    public class ToggleView : View
    {
        public ToggleView(string text,bool initValue = false)
        {
            Text = text;
            Toggle = new Property<bool>(initValue);
        }
        public string Text { get; set; }
        public Property<bool> Toggle { get; private set; }
        protected override void OnGUI()
        {
            Toggle.Value = GUILayout.Toggle(Toggle.Value, Text);
        }
    }

Property:数据工具类。泛型,有一个Value值,一个Action 的委托,一个委托的List(用来解除绑定),一个绑定委托的方法,一个解除绑定的方法。TextAreaView输入参数的更改以及ToggleView点击完成之后更改的事件都是写在这个数据类型中。

[Serializable]
    public class Property<T>
    {
        public Property()
        {

        }
        //第一次初始化使用,即第一次不管Value是否改变都必定触发绑定的委托,主要针对第一次显示已完成
        private bool setted = false;
        public Property(T initValue)
        {
            mValue = initValue;
        }

        public T Value
        {
            get => mValue;
            set
            {
                if (!value.Equals(mValue) || !setted)
                {
                    mValue = value;
                    mSetter?.Invoke(mValue);

                    setted = true;
                }
            }
        }
        /// <summary>
        /// TODO:注销功能也要做下
        /// </summary>
        /// <param name="setter"></param>
        public void Bind(Action<T> setter)
        {
            mSetter += setter;
            mBindings.Add(setter);
        }
        //不明白
        public void UnBindAll()
        {
            foreach (var binding in mBindings)
            {
                mSetter -= binding;
            }
        }
        private T mValue = default;
        private event Action<T> mSetter;
        private Func<T> mGetter => () => mValue;
        private List<Action<T>> mBindings { get; set; } = new List<Action<T>>();
    }

到这一步,EGOWindow(脚本代码类)中可以创建一个List,每创建一个View时添加,然后写一个在初始化方法中for循环调用每一个View的OnDrawGUI(调用OnGUI)。至此,面向过程改为面向对象,代码变成了一坨一坨的XXView,依旧有改进的空间。

布局:
在这里插入图片描述
如图所示,我们需要两种布局,即水平与垂直。
先创建一个ILayout接口。

    public interface ILayout:IView
    {
        void AddChild(IView view);
        void RemoveChild(IView view);
    }

继承IView,拥有添加子类以及移除子类的方法。
创建一个基类Layout

public abstract class Layout : View, ILayout
    {
        List<IView> Children = new List<IView>();

        Queue<Action> Commands = new Queue<Action>(); 
        public void AddChild(IView view)
        {
            Children.Add(view);
            view.Parent = this;
        }
        public void RemoveChild(IView view)
        {
            //牵扯到foreach,异常报错,应该排序执行
            Commands.Enqueue(() => { Children.Remove(view); });
        }
        protected override void OnGUI()
        {
            OnGUIBegin();
            foreach (var child in Children)
            {
                child.DrawGUI();
            }
            OnGUIEnd();

            while(Commands.Count>0)
            {
                Commands.Dequeue().Invoke();
            }
        }
        //表示行/列开始
        protected abstract void OnGUIBegin();
        //表示行/列结束
        protected abstract void OnGUIEnd();
    }

分别创建水平布局HorizontallLayout与垂直布局VerticalLayout

    public class HorizontalLayout : Layout
    {
        protected override void OnGUIBegin()
        {
            GUILayout.BeginHorizontal();
        }

        protected override void OnGUIEnd()
        {
            GUILayout.EndHorizontal();
        }
    }
public class VerticalLayout : Layout 
    {
        public string Style { get; set; }

        public VerticalLayout(string style = null)
        {
            Style = style;
        }

        protected override void OnGUIBegin()
        {
            if (string.IsNullOrEmpty(Style))
            {
                GUILayout.BeginVertical();
            }
            else
            {
                GUILayout.BeginVertical(Style);
            }
        }

        protected override void OnGUIEnd()
        {
            GUILayout.EndVertical();
        }
    }

很简单,实现begin与end方法即可,垂直布局比水平多一个style。

TodoView继承于水平布局,有一个Model参数,以及ToggleView与删除Button。当ToggleView的值改变时,model里的值也会改变(在创建代码中绑定了保存委托),因此点击单选框时就会触发保存功能。具体是图下这部分。
在这里插入图片描述

public class TodoView : HorizontalLayout
    {
        public Todo Model { get; }

        public TodoView(Todo model)
        {
            Model = model;
            var toggleView = new ToggleView(Model.Context, Model.Finished.Value);
            toggleView.Toggle.Bind(value => 
            {
                Model.Finished.Value = value;
            });
            AddChild(toggleView);
            var buttonView = new ButtonView("删除", () =>
            {
                Model.Finished.UnBindAll();
                //list中使用移除时,不能用foerach,会影响
                ModelLoader.Model.Todos.Remove(Model);
                ModelLoader.Model.Save();
                this.RemoveFromParent();
            });
            AddChild(buttonView);
        }
    }

TodoListView,继承与垂直布局代码,有一个mShowFinished参数,控制完成、未完成按钮的显示,有两个button,有创建TodoView的方法。具体是图下这部分。
在这里插入图片描述

public class TodoListView : VerticalLayout
    {
        private Property<bool> mShowFinished = new Property<bool>(false);
        private ButtonView mShowUnFinishedButton = null;
        private ButtonView mShowFinishedButton = null;
        public TodoListView()
        {

            AddChild(new SpaceView());

            mShowUnFinishedButton = new ButtonView("显示未完成", OnShowUnFinishedBtnClick);
            mShowFinishedButton = new ButtonView("显示已完成", OnShowFinishedBtnClick);
            mShowFinished.Bind(newValue =>
            {
                if (newValue)
                {
                    mShowFinishedButton.Hide();
                    mShowUnFinishedButton.Show();
                }
                else
                {
                    mShowUnFinishedButton.Hide();
                    mShowFinishedButton.Show();
                }
            });
            AddChild(mShowUnFinishedButton);
            AddChild(mShowFinishedButton);
            //layout
            foreach (var todo in ModelLoader.Model.Todos)
            {
                CreateTodoView(todo);
            }
            AddChild(mTodosParentContainer);

            mShowFinished.Value = false;

        }

        ILayout mTodosParentContainer = new VerticalLayout("box"); 
        public void CreateTodoView(Todo model)
        {
            var todoView = new TodoView(model);
            mTodosParentContainer.AddChild(todoView);

            mShowFinished.Bind(showFinished =>
            {
                if (todoView.Model.Finished.Value == showFinished)
                {
                    todoView.Show();
                }
                else
                {
                    todoView.Hide();
                }
            });
        }

        void OnShowFinishedBtnClick()
        {
            mShowFinished.Value = true;
        }

        void OnShowUnFinishedBtnClick()
        {
            mShowFinished.Value = false;
        }        
    }

TodoListInputView,继承与垂直布局,具体是图下的这部分。有一个文本输入框,添加按钮,以及参数是Todo的委托,在外部赋值,添加的时候会调用。输入框值改变时会同时改变自己的string文本值。
在这里插入图片描述

public class TodoListInputView : VerticalLayout
    {
        public Action<Todo> OnTodoCreate;

        private string mInputContent = string.Empty;
        public TodoListInputView()
        {
            Style = "box";
            AddChild(new SpaceView());
            //var layout = new VerticalLayout("box");
            //AddChild(new CustomView(() =>
            //{
            //    GUILayout.BeginVertical("box");
            //}));
            var inputTextArea = new TextAreaView(mInputContent);
            inputTextArea.
                Content.
                Bind((newContent => mInputContent = newContent));
            AddChild(inputTextArea);
            AddChild(new ButtonView("添加", () =>
            {
                if (!string.IsNullOrEmpty(mInputContent))
                {
                    var newTodo = new Todo() { Context = mInputContent };
                    OnTodoCreate(newTodo);
                    mInputContent = string.Empty;
                }
            }));
        }
    }

现在布局做好了,需要一个控制器去控制它。
ViewController,有一个总的布局。以及创建View的方法。

    public abstract class ViewController
    {
        public VerticalLayout View = new VerticalLayout();
        public abstract void SetUpView();
    }

TodoListController,拥有TodoListInputView 与TodoListView ,正好是图下的两部分。
在这里插入图片描述
绑定了InputView添加时会调用的委托。

public class TodoListController: Framework.ViewController
    {
        public TodoListInputView InputView { get; set; } = new TodoListInputView();

        public TodoListView TodoListView { get; set; } = new TodoListView();


        public override void SetUpView()
        {
            View.AddChild(new CustomView(() =>
            {
                GUILayout.Toolbar(0, new string[] { "1", "2" });
            }));

            InputView.OnTodoCreate = (newTodo) =>
            {
                newTodo.Finished.Bind(_ => ModelLoader.Model.Save());
                ModelLoader.Model.Todos.Add(newTodo);
                ModelLoader.Model.Save();
                //创建一个新的行,即刚刚添加的
                TodoListView.CreateTodoView(newTodo);
            };

            View.AddChild(InputView);

            View.AddChild(TodoListView);
        }
    }

最后就是需要规划的Window类,做一个基类,继承与EditorWindow与IDispose(销毁时候用)。
自身有一个ViewController,有一个Init的方法,Open时调用,Init又会调用Onit虚方法(留给子类实现),OnGUI会调用自身的Viewtroller中的View的子类去调用OnGUI渲染。有一个CreateViewController方法去获取ViewController。

public abstract class Window : EditorWindow,IDisposable
    {
        public static Window MainWindow { get; protected set; }
        public ViewController ViewController { get; set; }

        public T CreateViewController<T>()where T: ViewController,new()
        {
            var t = new T();
            t.SetUpView();
            return t;
        }
        public static void Open<T>(string title) where T:Window
        {
            var window = GetWindow<T>(true);
            if (!isShowing)
            {
                window.titleContent = new GUIContent(title);
                window.Init();
                isShowing = true;
                window.Show();
            }
            else
            {
                isShowing = false;
                window.Dispose();
                window.Close();
            }
        }
        void Init()
        {
            OnInit();
        }
        private void OnGUI()
        {
            ViewController.View.DrawGUI();
        }
        public void Dispose()
        {
            OnDispose();
        }
        protected abstract void OnInit();
        protected abstract void OnDispose();
        protected static bool isShowing = false;
    }

最后是我们需要实现的EGOWindow,继承与Window。

public class EGOWindow : Window 
    {
        [MenuItem("EGO/MainWindow %t")]
        static void Open()
        {
            Open<EGOWindow>("EGO Window");
        }

        protected override void OnInit()
        {
            ViewController = CreateViewController<TodoListController>();
        }

        protected override void OnDispose()
        {

        }
        //子类不能写OnGUI,因为继承,子类再写会直接覆盖
        //private void OnGUI()
        //{
        //    //mViews.ForEach(view => view.DrawGUI());
        //    //ViewController.View.DrawGUI();
        //}
    }

最终代码只有这么几行,简洁多了。
总结一下运行的流程,EGOWindow 调用Open方法,Open方法中会调用OnInit初始化方法,拿到TodoListViewController,并且执行SetUpView方法以及自身会先创建TodoListInputView与TodoListView,会调用自身默认的构造函数。SetUpView主要是给TodoListInputView添加绑定事件。TodoInputListView调用自身构造函数,创建界面。TodoListView调用自身构造函数,拿到所有的Models,然后循环创建每一个TodoView。至此,界面搭建完成。
关于添加删除功能,添加的时候会调用TodoListView的CreateTodoView方法新创建一条,即可完成创建功能。
删除功能较为麻烦,每个View都有一个Parent,用XXLayout布局去规范他们,即Layout有一个List参数,AddChild时,将自己定义为他们的父类,删除时调用RemovefromParent方法,移除自己,实现则是Children.remove(view),因为Children所有参数都会在OnGUI中调用,所以应该创建一个Avtion队列,将每一个删除Action添加,在OnGUI中最后调用,避免出现空引用的报错。


数据结构升级。
目前只有完成和未完成两种状态,不够完善,希望增加一个进行中的状态,并且能记录完成的时间,首先需要组一个数据结构更新器。
基类:

    //参数是旧类型与新类型,将旧类型改为新类型
    public abstract class UpdateAction<TOldModel, TNewMoedl>
        where TOldModel : class
        where TNewMoedl : class
    {
        public TNewMoedl Result { get; private set; }
        public void Execute(object oldValue)
        {
            Result = ConvertOld2New(oldValue as TOldModel);
        }
        //子类需要重写的方法
        protected abstract TNewMoedl ConvertOld2New(TOldModel oldModel);
    }

实现的子类:

    public class ModelUpdateCommandV1 : UpdateAction<TodoList, EGO.v1.TodoList>
    {
        protected override EGO.v1.TodoList ConvertOld2New(EGO.TodoList oldModel)
        {
            var newTodoList = new EGO.v1.TodoList();
            foreach (var oldTodo in oldModel.Todos)
            {
                var newTodo = new EGO.v1.Todo();
                newTodo.Content = oldTodo.Content;
                newTodo.State.Value = oldTodo.Finished.Value ? EGO.v1.TodoState.Done : EGO.v1.TodoState.NotStart;
                newTodoList.Todos.Add(newTodo);
            }
            return newTodoList;
        }
    }

两种数据类型的区别。

namespace EGO.v1
{
    [Serializable]
    public class TodoList
    {
        public List<Todo> Todos = new List<Todo>();
    }
    [Serializable]
    public class Todo
    {
        public string Content;

        public DateTime CreateAt = DateTime.Now;

        public DateTime FinshedAt;

        public DateTime StartTime;

        public Property<TodoState> State = new Property<TodoState>(TodoState.NotStart);

        public Property<bool> Finished = new Property<bool>();
    }
    [Serializable]
    public enum TodoState
    {
        NotStart,
        Started,
        Done,
    }
}


namespace EGO
{
    [Serializable]
    public class TodoList
    {
        public List<Todo> Todos = new List<Todo>();
    }
    [Serializable]
    public class Todo
    {
        public string Content;
        public Property<bool> Finished = new Property<bool>();
    }
}

可以看出,区别不打,所谓升级,就是创建新类型,然后拿到旧类型,对已有存在的值进行初始化。
加载的时候,直接拿到数据进行反序列化,写在tryCatch中,如果有异常,那么则调用更新代码。

public static TModel Load()
        {
            var todoContent = EditorPrefs.GetString(KEY, string.Empty);
            Debug.Log(todoContent);
            if (string.IsNullOrEmpty(todoContent))
            {
                return mModel = new TModel();
            }
            try
            {
                mModel = JsonConvert.DeserializeObject<TModel>(todoContent);               
            }
            catch (Exception e)
            {
                mModel = ModelUpdater.Update<TModel>(todoContent);
            }
            return mModel ;

        }

数据更新非常麻烦,所有用到的数据都需要更改,例如XXView。
在这里插入图片描述
新的数据类型中用state取代Finshed,所有原来的代码都需要修改。


已完成未完成希望根据表头修改。
归功于前面做的铺垫,这个功能做起来非常的简单,只需要点击表头的事件绑定ListTodoView的是否显示参数即可。
在这里插入图片描述
TodoListView的ShowFished参数控制显示哪些,其实即使把button的事件添加到了toolBar中。


Button按钮希望添加样式。
非常简单,buttonView的构造函数自带。在这里插入图片描述
在这里插入图片描述
但是我一定要吐槽一下这个老师讲课,显示在View中增加在这里插入图片描述
又在IView中增加在这里插入图片描述
最后button中还增加
在这里插入图片描述
我实在不知道意义何在。这个老师讲课听起来是真的累。
实际效果
在这里插入图片描述


给渲染框架提供样式的设置。
View添加两个参数,一个私有,一个提供给子类。一个增加的方法
在这里插入图片描述
子类创建的时候会将相关参数传入到创建时GUILaout.XX的API中,如图下的Button。
在这里插入图片描述
吐槽:正常来说只需要一个参数就够了,但是此处用了两个,最无语的是将另一个List参数转换为数组赋值给另一个,完全不知道意义所在。
在这里插入图片描述
另外创建一个拓展方法,方便调用(此处特指单独设置宽度)
在这里插入图片描述


拆分ViewController
目前,脚本中只有一个View,有一个控制参数的值确定显示哪一部分,其实这样非常不规范,此处额外创建一个View脚本,内容和当前TodoListView基本一样,不过一个只显示已完成,一个显示未完成,然后又toolbar来控制显示哪一个View。
此处较为简单,就不贴代码了。


添加正在进行状态。
改动,改为三个按钮加上label,而不是单独的toggleview,通过点击按钮更改状态,并且确定显示哪一个按钮。并执行相应的事件。
在这里插入图片描述


实时更新每个任务的状态,即,我点击了完成,当前页面就不应该显示。
这个改动简单,只要将自己隐藏即可。但是考虑到框架的问题,从跟上需要加上一个Refresh的方法。
吐槽:
此处todoView的Refresh方法应该是调用hide将自己隐藏,但是作者脑子抽了,非要将自己从父类移除,切回来可能会出问题(如果对状态进行多次更改),并且还牵扯到执行顺序加了一堆逻辑,后续还有改动,自出暂时不贴代码,不得不吐槽这个作者,真的是自己一边做一边讲,经常带进歪路。


已完成按照 完成的日期显示,很简单的小任务,对List进行排序即可。
计算每天用时,没有什么意义的功能,先按照GroupBy对List进行分组,然后计算时间即可。
完成的事项要显示完成的时间,没难度,多年加一个labelView传入相关参数即可。以上是没什么难度,调用相关API执行相关事件即可。


为Todo设置优先级。
这个任务需要用到一个新的组件EditorGUILayout.EnumPopup
在这里插入图片描述
在这里插入图片描述
本质上没有难度,在Horizontal布局里添加一下即可。


实时刷新功能。
这个功能非常重要,目前来说,有已完成列表界面与未完成列表界面。
目前只是实现了数据的更改保存,但是界面上不会更改,需要重新打开一次才行,这明显是不合理的,因此需要做一个刷新的功能。
首先第一个问题:代码中用到了forearch,foreach的数据在使用的时候是不能进行增加或者删减的,否则会报错,所以首先我们需要在改动数据之后下一帧再执行。
因此在基类中新增加一个方法
在这里插入图片描述
Command是List,会再OnGUI渲染完当前帧所有事物(即foreach已完成对数据的使用)后调用。
回到TudoListView中,对构造函数进行更改。
在这里插入图片描述
切换界面的时候会调用OnShow的方法,OnShow中会调用下一帧刷新方法
在这里插入图片描述
在这里插入图片描述
至此,刷新功能完成。但是有非常重要的一点需要注意!刷新界面会导致一些事件失效,因为每一次更改都会刷新界面,刷新界面时会清楚原本所有的东西全部重新渲染,包括绑定的事件,所以需要将一些事件的内容写在刷新的方法内,例如
在这里插入图片描述
原本是数据改动之后再判断是什么级别,但是因为改动之后内容已经全部清空,又会变回A,所以需要直接将判断写在方法中。至此,刷新功能完成。
注意点:渲染顺序的冲突解决。


分类管理的功能实现。
先看下布局。
在这里插入图片描述
总得来说页面和清单、已完成没有什么不同,但是多了一个窗口。
并且切换页面的时候需要关闭这个窗口。
与todoListView和FinishedListView一样,我们需要创建一个CategoryListView。
功能与上面一样,区别的是点击“+”号弹出新窗口,我们需要创建一个新的window
在这里插入图片描述
然后在View层写相关的方法即可。在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
点击新增或者编辑即会创建页面。
在这里插入图片描述
切换页面时会hide,会调用close方法。
在这里插入图片描述
这处代码较复杂,很绕。


全局事件消息机制。
顶一个一个字典List<int,Action>,一个枚举,一个注册的方法,一个取消注册的方法,还有调用的方法。
注册时,将传进来的T转换为int,Action添加到字典中,调用时传入int即可。


消息队列机制。
举例:
今天你吃了吗?
是?否?
吃的什么?
米饭?面条?
工作中经常遇到这些问题,如何解决呢?

第一个类,问题类。
有一个Title方法,可以设置标题,即问题。
有一个Menu方法,可以设置答案。
有一个回调,绑定触发下一个问题的方法,在每次选择答案后触发。

第二个类,消息队列类。
有一个Enqueue,里面装所有的问题,
有一个调用的方法,先判断队列中是否还有问题,如果有,则弹出,没有则return,并且将该方法绑定到问题类的回调。

流程,消息队列类调用运行方法,弹出第一个问题,选择答案,通过昂定的回调再次调用运行方法。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值