none
DatagridView自定义列事件处理问题 RRS feed

  • 问题

  • 我自定义了一个带按钮的文本框列,带按钮的文本框控件是我自定义的,目的是使用按钮弹出选单选择,然后将选择的内用给文本框,传给单元格.当然按钮有一个事件了,Button.Click,在InitializeEditingControl处理过程中将Button.Click指定到方法 OnButtonClick,在这之中添加一些EventArgs参数,传给其OwningColumn。窗体中可以定义该Column的ButtonClick事件处理过程,最后发现点击第一次按钮正常,再点击就会响应两次再点响应四次...,跟踪了一下发现是 OnButtonClick被多次调用。这个问题似乎是事件注册问题,但又不像.为什么不改变单元格的值就没问题了呢。

     


    陈锦巍
    2012年1月10日 10:01

答案

  • Hi,
    根据您提供的代码,我发现,注释掉   if (e.ColumnIndex==-1) 这一行有关代码后,会出现下面情况:1. 第一次点击按钮A,调用1次TextBoxExtendCell的OnButtonClick方法。(此后在中间没有点击其他按钮的情况下,继续点击A,均为1次)  2.第二次点击按钮B,调用两次TextBoxExtendCell的OnButtonClick方法。  3.第三次点击按钮C,调用三次TextBoxExtendCell的OnButtonClick方法。 4.第四次点击按钮A,调用4次TextBoxExtendCell 的OnButtonClick方法。

    究其原因,在于每次InitializeEditingControl 方法中,都会在事件委托链表ctl.SelectButtonClick中添加一个订阅者:

    ctl.SelectButtonClick += new ButtonClickEventHandler(OnButtonClick);

    而这一次订阅始终没有删除。所以每次控件调用InitializeEditingControl 方法,都会增加一次订阅。

    我增加了下面的方法:

            // TextBoxExtendCell 代码中
    
            public override void DetachEditingControl()
            {
                base.DetachEditingControl();
    
                TextBoxExtentEditingControl ctl = DataGridView.EditingControl as TextBoxExtentEditingControl;
    
                ctl.SelectButtonClick -= new ButtonClickEventHandler(OnButtonClick);
    
            }

    您可以在下面的链接中下载更新后的项目:

    https://skydrive.live.com/redir.aspx?cid=37142ebae462f7f1&resid=37142EBAE462F7F1!132&parid=37142EBAE462F7F1!118

    谢谢!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us

    • 已标记为答案 陈锦巍 2012年2月15日 1:25
    2012年2月7日 15:47
    版主

全部回复

  • Hi 陈锦巍,

    欢迎来到MSDN论坛!

    您能够把有关代码分享给我们么?单凭目前的信息,我无法重现该问题.

    谢谢.

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us
    2012年1月11日 9:42
    版主
  • 我自定义了一个带按钮的文本框列,带按钮的文本框控件是我自定义的,目的是使用按钮弹出选单选择,然后将选择的内用给文本框,传给单元格.当然按钮有一个事件了,Button.Click,在InitializeEditingControl处理过程中将Button.Click指定到方法 OnButtonClick,在这之中添加一些EventArgs参数,传给其OwningColumn。窗体中可以定义该Column的ButtonClick事件处理过程,最后发现点击第一次按钮正常,再点击就会响应两次再点响应四次...,跟踪了一下发现是 OnButtonClick被多次调用。这个问题似乎是事件注册问题,但又不像.为什么不改变单元格的值就没问题了呢。

     


    陈锦巍


    你好,

    看你对自定义控件的描述,你可以添加 控件自带的DataGridViewComboBoxColumn 列,这个就能满足你的要求,下拉列表选择文本,

    参考代码:

    DataGridViewComboBoxColumn dgvcbox = new DataGridViewComboBoxColumn();
                dgvcbox.Items.AddRange(new string []{"选项一","选项二","选项三","选项四"});
                dataGridView1.Columns.Add(dgvcbox );

    希望对你有帮助,

    谢谢,

    Allen


    2012年1月14日 13:59
  • 之所以想实现这样一列是因为ComboBox列下拉列表不可能显示太多的栏位信息,如果可以的话,我到更愿实现下拉一个Datagridview列表,因为有些列还需要format显示内容。目前是点按钮弹出选择窗体(在窗体就可以实现好多方便的功能了,定位、排序等),选择后将结果传给单元格。

    该类的代码如下:此代码倒是解决了多次响应问题,是通过判断ColumnIndex方式实现的,虽然不会多次响应,但会多次判断,觉得也不太好。权宜之计罢了。

    using System;
    using System.Windows.Forms;
    using System.ComponentModel;
    namespace CJWSoft.DataGridExtend
    {
       public delegate void ButtonClickEventHandler(object sender, CellButtonEventArgs e);
        public class TextBoxExtendColumn : DataGridViewColumn
        {
            [Browsable(true), Description("选择按钮单击事件。"), Category("行为")]
            public event ButtonClickEventHandler SelectButtonClick;
            bool onlyChangeValueOnButton;
            public bool OnlyChangeValueOnButton
            {
                get
                {
                    return onlyChangeValueOnButton;
                }
                set
                {
                    onlyChangeValueOnButton=value;
                }
            }
            public TextBoxExtendColumn()
                : base(new TextBoxExtendCell())
            {

            }
            internal protected virtual void OnButtonClick(object sender, CellButtonEventArgs e)
            {
                //ButtonClickEventHandler = OnButtonClick();
                if (SelectButtonClick != null)
                {
                    SelectButtonClick(this, e);
                    //HaveRegisit = true;
                }

            }

               
            public override object Clone()
            {
                TextBoxExtendColumn Col = (TextBoxExtendColumn)base.Clone();
                Col.OnlyChangeValueOnButton = this.OnlyChangeValueOnButton;
                return Col;
            }
        }

        public class TextBoxExtendCell : DataGridViewTextBoxCell
        {
            [Browsable(true), Description("选择按钮单击事件。"), Category("行为")]
            public TextBoxExtendCell()
                : base()
            {

            }
            public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle)
            {
                base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle);
                TextBoxExtentEditingControl ctl = DataGridView.EditingControl as TextBoxExtentEditingControl;
                    if (this.OwningColumn.GetType() == typeof(TextBoxExtendColumn))
                    {
                        ctl.ReadOnly = ((TextBoxExtendColumn)this.OwningColumn).OnlyChangeValueOnButton;
                        //ctl.SelectButtonClick += ((TextBoxExtendColumn)this.OwningColumn).SelectButtonClick;
                    }
                    ctl.BackColor = System.Drawing.Color.White;
                    ctl.SelectButtonClick += OnButtonClick;
                    if (this.Value == null)
                        ctl.Text = "";
                    else
                        ctl.Text = this.Value.ToString();
            }
            public override Type EditType
            {
                get
                {
                    return typeof(TextBoxExtentEditingControl);
                }
            }
            protected void OnButtonClick(object sender, CellButtonEventArgs e)
            {

                //通过检测ColumnIndex==-1判断是否响应过
                if (e.ColumnIndex==-1)
                {

                //将ColumnIndex赋值为当前列索引。
                     e.ColumnIndex = this.ColumnIndex;
                    ((TextBoxExtendColumn)this.OwningColumn).OnButtonClick(sender, e);
                  
                }
            }
        }

        class TextBoxExtentEditingControl:TextBoxEx, IDataGridViewEditingControl
        {
            public event ButtonClickEventHandler SelectButtonClick;
            DataGridView dataGridView;
            private bool valueChanged = false;
            int rowIndex;
             public TextBoxExtentEditingControl()
            {
                this.ShowButton = true;
                this.ButtonOnDoubleClick = true;
                this.EnterShowId = false;
                //this.ButtonClick += OnButtonClick;
            }
            public void ApplyCellStyleToEditingControl(DataGridViewCellStyle dataGridViewCellStyle)
            {
            }
            public DataGridView EditingControlDataGridView
            {
                get
                {
                    return dataGridView;
                }
                set
                {
                    dataGridView = value;
                }
            }

            public object EditingControlFormattedValue
            {
                get
                {
                    return this.Text.ToString();
                }
                set
                {
                    if (value is String)
                    {
                        try
                        {
                            this.Text= value.ToString();
                        }
                        catch
                        {
                            this.Text = "";
                        }
                    }
                }
            }

            public int EditingControlRowIndex
            {
                get
                {
                    return rowIndex;
                }
                set
                {
                    rowIndex = value;
                }
            }

            public bool EditingControlValueChanged
            {
                get
                {
                    return valueChanged;
                }
                set
                {
                    valueChanged = value;
                }
            }

            public bool EditingControlWantsInputKey(Keys key, bool dataGridViewWantsInputKey)
            {
                if ((this.SelectionStart == 0 && key == Keys.Left) ||
                     (this.SelectionStart == this.TextLength && key == Keys.Right))
                {
                    this.dataGridView.EndEdit();
                    return !dataGridViewWantsInputKey;
                }
                else
                {
                    switch (key & Keys.KeyCode)
                    {
                        case Keys.Left:
                        case Keys.Right:
                               return true;
                        default:
                               return !dataGridViewWantsInputKey;
                    }
                }
            }

            public Cursor EditingPanelCursor
            {
                get { return base.Cursor; }
            }

            public object GetEditingControlFormattedValue(DataGridViewDataErrorContexts context)
            {
                return EditingControlFormattedValue;
            }

            public void PrepareEditingControlForEdit(bool selectAll)
            {
                //throw new NotImplementedException();
            }

            public bool RepositionEditingControlOnValueChange
            {
                get { return false; }
            }
            protected override void OnTextChanged(EventArgs e)
            {
                valueChanged = true;
                //this.Id = Text;
                //this.Description = Text; ;
                this.EditingControlDataGridView.NotifyCurrentCellDirty(true);
                base.OnTextChanged(e);
            }
            protected override void OnButtonClick(object sender, EventArgs e)
            {
                //base.OnButtonClick(sender, e);

            //响应之初将事件参数的ColumnIndex设为-1
                CellButtonEventArgs eg = new CellButtonEventArgs() { RowIndex = this.rowIndex,ColumnIndex=-1, Value = Text };
                this.SelectButtonClick(sender, eg);
                if (eg.Value != Text)
                {
                    this.Text = eg.Value;
                    this.Focus();
                }
            }
        }
        public class CellButtonEventArgs : EventArgs
        {
            public int RowIndex { get; set; }
            public int ColumnIndex { get; set; }
            public string Value { get; set; }
            public string FormatValue { get; set; }
            public TextBoxEx TextBox { get; set; }
        }
    }


    陈锦巍
    2012年1月25日 3:00
  • 如果不用上述方法通过检测处理,当点击button弹出窗口选择了之后,再点就会响应两次,再点就四次....
    陈锦巍
    2012年1月25日 3:05
  • Hi 陈锦巍,

    新年好!

    我没能够重建您的项目,方便的话,您能否把项目传到SkyDrive上面么?

    另外,我注意到您在Column 跟Cell Control列都对 buttonClick事件进行了处理.是不是由于在这里出现了重复的关系呢?

            // public class TextBoxExtendColumn : DataGridViewColumn 中   
    
            internal protected virtual void OnButtonClick(object sender, CellButtonEventArgs e)
            {
                //ButtonClickEventHandler = OnButtonClick();
                if (SelectButtonClick != null)
                {
                    SelectButtonClick(this, e);
                    //HaveRegisit = true;
                }
    
            }
    
            // class TextBoxExtentEditingControl:TextBoxEx, IDataGridViewEditingControl 中
            protected override void OnButtonClick(object sender, EventArgs e)
            {
                //base.OnButtonClick(sender, e);
    
            //响应之初将事件参数的ColumnIndex设为-1
                CellButtonEventArgs eg = new CellButtonEventArgs() { RowIndex = this.rowIndex,ColumnIndex=-1, Value = Text };
                this.SelectButtonClick(sender, eg);
                if (eg.Value != Text)
                {
                    this.Text = eg.Value;
                    this.Focus();
                }
            }
    

    期待您的回复!

    祝,顺利!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us
    2012年1月27日 6:22
    版主
  • Column 跟Cell Control列都对 buttonClick事件进行了处理是因为我想在使用时,直接写该Column的SelectButtonClick事件处理过程。因此事这样实现样的:

     TextBoxExtentEditingControl的ButtonClick(我的自定义控件有这一事件,且是通过OnButtonClick方法激发的,因此此处重写该方法激发,暴漏给外面)->TextBoxExtendCell的OnButtonClick处理(调用Column OnButtonClick)->激发Column SelectButtonClick事件。

    本来代码没有Control里的处理,在 InitializeEditingControl时注册该控件的ButtonClick事件也可以。但实际使用时产生了多次激发,而且有时又不会激发(就像是事件被注销了一样)。所以为解决这个问题才在contro里用代码调用了一下,并且可以给Column赋一个初值-1。这样就可以在Cell的OnButtonClick里判断该值如为-1则调用Column里的OnButtonClick,并将其值改为Column索引值。

    虽可以避免多次激发,但可以想象该过程还是会多次执行的。

    后来又发现了这样一个问题,如果你有两个以上该种类型的列,又会有问题。那就是ColumnIndex的赋值,其取值会是前一个Column的值(访问事件参数e.ColumnIndex)。当然如果在此之间进入过其他类型的列,就会获得正确的值,或许是又会进行 InitializeEditingControl的原因吧。当然也可以解决这种问题

     If myGridView.CurrentCell.ColumnIndex = ItemId.Index Then
                SelectItemId_Click(sender, e)
            ElseIf MyDtGrid.CurrentCell.ColumnIndex = WhseId.Index Then
                SelectWhse(sender, e)
            End If

    总之这种自定义列制作起来总感觉有点不太如意。或许是我的方法有问题。

    比如说如果我在我的扩展DataGridView里定义一个CellbuttonClick(当然了还得判断Cell的类型),用这种方式不会产生多次激发问题。也就是把事件通过DataGridViewExt去暴漏。

    很多情形下都会用这种方式做,但这DataGridView如果这样定义一个事件,不好看,因为好多咧都没有这样一个事件。当然自己开发自己用没什么问题,要是从规范来讲,假定别人也会用,那就会引起误会了。

     


    陈锦巍
    2012年1月30日 9:45
  • 已将该TextBoxExtendColumn 以及所使用的TextBoxExt控件传到SkyDrive上了。我是因为还有别的功能要求,自定义了DataGridView(继承自DataGridView).在该扩展空间工程中又定义的TextBoxExtendColumn,使用时我的自定义DataGridView里就会有这样一列。

    前面的代码之所以没法重建是因为缺了我这个TextBoxExt控件。需要引用该控件。

     


    陈锦巍
    2012年1月30日 10:47
  • Hi 陈锦巍,

    谢谢您的回复.

    很高兴您愿意与我们分享您的项目,不过,您能够把SkyDrive中该项目文件的地址发给我们么? 同时,请您别忘了把文件的Shared Folder 设置为Everyone,这样我们才能有访问权限.

    另外,根据您的需求,也许下面这个链接会对您有所帮助:

    How to: Host Controls in Windows Forms DataGridView Cells
    http://msdn.microsoft.com/en-us/library/7tas5c80.aspx

    祝,顺利!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us
    2012年1月31日 1:50
    版主
  • 不好意思第一次用Skydirve还不太会用,我是从网上搜索进入的,由于我本机已经登陆PassPort所以我就直接进入了我的Skydirve,我已经把文件放到公开里了。这个地址是我进入公开后的地址

    https://skydrive.live.com/?cid=1ED894B509F6C9F3&id=1ED894B509F6C9F3%21121

    不知道是不是,我退出登录后也可以访问,想必你那边也可以。


    陈锦巍
    2012年2月3日 5:40
  • 您给出的链接是关于自定义列的,我也是这么做的。只是上面没有牵涉到关于事件如何处理问题。我所用的单元格输入控件时有个按钮事件的,单元格的内容将会从点按钮填出选择窗体,选择后填入。因而碰到了事件响应问题。


    陈锦巍
    2012年2月3日 5:44
  • That's Ok!

    我已经下载了您提供的链接。关于该问题有任何进展,我会及时让您知晓。

    谢谢!

    祝,顺利!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us
    2012年2月3日 5:46
    版主
  • 谢谢您对我提出的问题的关注。


    陈锦巍
    2012年2月4日 3:51
  • Hi,
    根据您提供的代码,我发现,注释掉   if (e.ColumnIndex==-1) 这一行有关代码后,会出现下面情况:1. 第一次点击按钮A,调用1次TextBoxExtendCell的OnButtonClick方法。(此后在中间没有点击其他按钮的情况下,继续点击A,均为1次)  2.第二次点击按钮B,调用两次TextBoxExtendCell的OnButtonClick方法。  3.第三次点击按钮C,调用三次TextBoxExtendCell的OnButtonClick方法。 4.第四次点击按钮A,调用4次TextBoxExtendCell 的OnButtonClick方法。

    究其原因,在于每次InitializeEditingControl 方法中,都会在事件委托链表ctl.SelectButtonClick中添加一个订阅者:

    ctl.SelectButtonClick += new ButtonClickEventHandler(OnButtonClick);

    而这一次订阅始终没有删除。所以每次控件调用InitializeEditingControl 方法,都会增加一次订阅。

    我增加了下面的方法:

            // TextBoxExtendCell 代码中
    
            public override void DetachEditingControl()
            {
                base.DetachEditingControl();
    
                TextBoxExtentEditingControl ctl = DataGridView.EditingControl as TextBoxExtentEditingControl;
    
                ctl.SelectButtonClick -= new ButtonClickEventHandler(OnButtonClick);
    
            }

    您可以在下面的链接中下载更新后的项目:

    https://skydrive.live.com/redir.aspx?cid=37142ebae462f7f1&resid=37142EBAE462F7F1!132&parid=37142EBAE462F7F1!118

    谢谢!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us

    • 已标记为答案 陈锦巍 2012年2月15日 1:25
    2012年2月7日 15:47
    版主
  • Hi 陈锦巍,

    请问您的问题处理的怎么样了?

    如果您需要任何帮助,请您告诉我们.

    谢谢!

    祝,一切顺利!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us

    2012年2月9日 7:45
    版主
  • 不好意思前些天不在今天刚看到,看到您重写了DetachEditingControl我就明白了。不瞒您说,原来我也试图将事件注销,但那时湿了好多方法,甚至判断过ctl是否存在等。也发现是委托链的问题,就不知该在何处注销事件。看你上面的代码应该没问题了。

    陈锦巍


    2012年2月9日 12:35
  • 把您添加的那过程家到我项目中的TextBoxExtendCell类中,发现在使用时还是有问题的。运行你的项目

    又没问题。

    我这个项目里就是点击该单元格的Button潭出选单将选择项赋与单元格。我试了只要e.value的值一改变就会多次执行,而且不只加一,似乎是呈几何倍数增长。我在我项目中又加了一窗体添加我的扩展DataGridView增加该种类型的列,在SelectButtonClic里面写e.value+="a",没问题啊?后来到我业务窗体就有问题了。后来想了想,不同之处在于我的项目中是使用了数据邦定的。想来也许就是数据邦定造成的。

    进一步发现,即便该列不去绑定数据源,而其他列绑定到了数据源同样也容易多次激发的。

    是不是我给该单元格赋值的办法有问题?单元格控件被激活了,点按钮出选单,取得结果后。应该怎样赋值才合适?


    陈锦巍



    2012年2月9日 14:02
  • Hi 陈锦巍,

    谢谢您的反馈!

    按照您描述的情形来看——“我试了只要e.value的值一改变就会多次执行,而且不只加一,似乎是呈几何倍数增长。”——改变e.value值的过程中应该是重新调用了InitialEditingControl之类的函数,但又并没有调用对应的DetachEditingControl函数。 建议您在这两个函数内设置断点调试下。看下目前出现这种“类似几何增长”的增加模式是怎样出现的。

    另外,我个人觉得,除了用DetachEditingControl中unregister订阅者以外,还可以设置一个全局变量 int i,在原来的InitialEditingControl函数中每次需要进行register时对 i 的值进行判断。

    谢谢!

    yoyo


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us

    2012年2月10日 3:18
    版主
  • 好回头我试一下,我已将你的工程进行了数据邦定,结果也测出了这个问题。下面的链接就是绑定后的工程。

    https://skydrive.live.com/#cid=1ED894B509F6C9F3&id=1ED894B509F6C9F3%21125

    我找你的方法试了一下,是改善了不少,但还是会有重复激发问题,也不知为什么。


    陈锦巍


    2012年2月11日 2:10
  • 现在像是解决了,这样改,在 InitializeEditingControl是判断事件是否已注册,没注册再注册事件,这样的话则会避免多次注册该事件了,同时DetachEditingControl()也可以省了。

    由于 InitializeEditingControl里不能用ctl.SelectButtonClick==null来判断,目前只有通过GetInvocationList获得委托链计数来解决了。

            public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle)
            {
                base.InitializeEditingControl(rowIndex, initialFormattedValue, dataGridViewCellStyle);
                TextBoxExtentEditingControl ctl = DataGridView.EditingControl as TextBoxExtentEditingControl;
                if (this.OwningColumn.GetType() == typeof(TextBoxExtendColumn))
                {
                    ctl.ReadOnly = ((TextBoxExtendColumn)this.OwningColumn).OnlyChangeValueOnButton;
                }
                ctl.BackColor = System.Drawing.Color.White;
            //判断事件是否已注册
                if (ctl.GetDelegateCount() == 0)
                {
                    ctl.SelectButtonClick += OnButtonClick;
                }
                try
                {
                    if (this.Value == null)
                        ctl.Text = "";
                    else
                        ctl.Text = this.Value.ToString();
                    columnIndex = this.OwningColumn.Index;
                }
                catch (Exception)
                {
                    ctl.Text = "";
                }
    
    
            }
    
    
      class TextBoxExtentEditingControl:TextBoxEx, IDataGridViewEditingControl
        {
            public event ButtonClickEventHandler SelectButtonClick;
            DataGridView dataGridView;
            private bool valueChanged = false;
            int rowIndex;
    //获得SelectButtonClick事件注册计数
            public int GetDelegateCount() 
            {
                if (this.SelectButtonClick==null)
                {
                    return 0;
                }
                System.Delegate[] Dg=SelectButtonClick.GetInvocationList();
                return Dg.Length;
    
            }
    .
    .
    .


    陈锦巍


    2012年2月11日 8:11
  • 补充说明:DetachEditingControl()如果省了,虽然不会造成多次激发,但是对于DataGridView有多个这样的列时,单元格的OwningColumn可能会混乱。比如在两个相同的这样的列之间切换(进入列),则在OnButtonClick方法中,OwningColumn总是会是第一次进入的那个列。我前面的代码之所以定义一个columnIndex字段就是发现了这一点,希望在InitializeEditingControl方法中设定该值,结果OnButtonClick方法中却发现columnIndex又变了。这就是我上面[2012年1月30日 9:45]帖中所描述的“如果你有两个以上该种类型的列,又会有问题”问题。

    而重写DetachEditingControl()及时注销掉该事件,在进入单元格时重新注册该事件,OnButtonClick才能获得正确的OwningColumn。将OnButtonClick改为:

    protected void OnButtonClick(object sender, CellButtonEventArgs e) {

    //此时this.ColumnIndex正是该单元格的列索引。直接将此值赋于事件参数的ColumnIndex即可。

    e.ColumnIndex = this.ColumnIndex; ((TextBoxExtendColumn)this.OwningColumn).OnButtonClick(sender, e); }



    陈锦巍

    2012年2月11日 9:24
  • 再次感谢Yoyo Jiang对我的问题的关注和帮助。

    陈锦巍

    2012年2月11日 9:26
  • 很高兴您的问题得到解决。

    如果方便的话,您能够把对您有帮助的回复标记为答案么?这样也可以帮助到论坛里其他朋友。

    谢谢。


    Yoyo Jiang[MSFT]
    MSDN Community Support | Feedback to us

    2012年2月13日 1:40
    版主