jQuery MiniUI,Ajax Web开发

ExtJS 6:将日期字段修改为日期时间字段(一)

都快一年没写过博客了,主要原因是各种忙,项目要忙,写书要忙,总之就是忙。忙有忙的好啊,忙意味着经验值又涨了,但离升级到下一等级估计还需要很长时间。在项目中的一些开发经验,已经总结到已经交稿的《Ext JS 6.2 实战》中,希望对大家有所帮助。由于一本书内容有限,因而有些东西还是得写写博客和大家交流。
在Ext JS 4时代,很少考虑自己去改扩展之类的,因为在官方论坛一搜基本都有了。但随着Ext JS越来越商业化,这方面的东西越来越少了,很多时候只能自己动手了,今天要讲的日期时间字段就是这样,在Ext JS 4时代,一搜有好多,但Ext JS 6的没几个。还好,这么多年的使用经验在身,给点耐心,还是做出来了。
要将日期字段(Ext.form.field.Date.html)修改为日期时间字段,关键的问题是如何将输入时间的INPUT元素插入到日期字段中。在最初的预想中,是直接将数字字段(Ext.form.field.Number)的HTML代码直接嵌入日期选择器(Ext.picker.Date)的模版中,经过试验,该方法是可行的,但要做的工作非常多,如为小时、分钟和秒的上、下按钮定义事件等等。
在准备实现这是功能的时候,发现了以下很有趣的代码:

beforeRender: function() {
        /*
         * days array for looping through 6 full weeks (6 weeks * 7 days)
         * Note that we explicitly force the size here so the template creates
         * all the appropriate cells.
         */
        var me = this,
            encode = Ext.String.htmlEncode,
            days = new Array(me.numDays),
            today = Ext.Date.format(new Date(), me.format);

        if (me.padding && !me.width) {
            me.cacheWidth();
        }

        me.monthBtn = new Ext.button.Split({
            ownerCt: me,
            ownerLayout: me.getComponentLayout(),
            text: '',
            tooltip: me.monthYearText,
            tabIndex: -1,
            ariaRole: 'presentation',
            listeners: {
                click: me.doShowMonthPicker,
                arrowclick: me.doShowMonthPicker,
                scope: me
            }
        });
        if (me.showToday) {
            me.todayBtn = new Ext.button.Button({
                ui: me.footerButtonUI,
                ownerCt: me,
                ownerLayout: me.getComponentLayout(),
                text: Ext.String.format(me.todayText, today),
                tooltip: Ext.String.format(me.todayTip, today),
                tooltipType: 'title',
                tabIndex: -1,
                ariaRole: 'presentation',
                handler: me.selectToday,
                scope: me
            });
        }

        me.callParent();

        Ext.applyIf(me, {
            renderData: {}
        });

        Ext.apply(me.renderData, {
            dayNames: me.dayNames,
            showToday: me.showToday,
            prevText: encode(me.prevText),
            nextText: encode(me.nextText),
            todayText: encode(me.todayText),
            ariaMinText: encode(me.ariaMinText),
            ariaMaxText: encode(me.ariaMaxText),
            ariaDisabledDaysText: encode(me.ariaDisabledDaysText),
            ariaDisabledDatesText: encode(me.ariaDisabledDatesText),
            days: days
        });

        me.protoEl.unselectable();
    },

在代码中,会发现月份按钮和今天按钮是使用Ext JS的按钮来实现的,而不是直接使用模版实现,也就是说,可以在模版中嵌入Ext JS的组件。这就好办了,把三个数字字段嵌入模版中就省事了,什么也不用干就基本实现了将时间输入字段嵌入到日期字段了。这个是怎么实现的呢?回头看看模版的定义代码:

 renderTpl: [
        '<div id="{id}-innerEl" data-ref="innerEl" role="presentation">',
            '<div class="{baseCls}-header">',
                '<div id="{id}-prevEl" data-ref="prevEl" class="{baseCls}-prev {baseCls}-arrow" role="presentation" title="{prevText}"></div>',
                '<div id="{id}-middleBtnEl" data-ref="middleBtnEl" class="{baseCls}-month" role="heading">{%this.renderMonthBtn(values, out)%}</div>',
                '<div id="{id}-nextEl" data-ref="nextEl" class="{baseCls}-next {baseCls}-arrow" role="presentation" title="{nextText}"></div>',
            '</div>',
            '<table role="grid" id="{id}-eventEl" data-ref="eventEl" class="{baseCls}-inner" cellspacing="0" tabindex="0" aria-readonly="true">',
                '<thead>',
                    '<tr role="row">',
                        '<tpl for="dayNames">',
                            '<th role="columnheader" class="{parent.baseCls}-column-header" aria-label="{.}">',
                                '<div role="presentation" class="{parent.baseCls}-column-header-inner">{.:this.firstInitial}</div>',
                            '</th>',
                        '</tpl>',
                    '</tr>',
                '</thead>',
                '<tbody>',
                    '<tr role="row">',
                        '<tpl for="days">',
                            '{#:this.isEndOfWeek}',
                            '<td role="gridcell">',
                                '<div hidefocus="on" class="{parent.baseCls}-date"></div>',
                            '</td>',
                        '</tpl>',
                    '</tr>',
                '</tbody>',
            '</table>',
            '<tpl if="showToday">',
                '<div id="{id}-footerEl" data-ref="footerEl" role="presentation" class="{baseCls}-footer">{%this.renderTodayBtn(values, out)%}</div>',
            '</tpl>',
            // These elements are used with Assistive Technologies such as screen readers
            '<div id="{id}-todayText" class="' + Ext.baseCSSPrefix + 'hidden-clip">{todayText}.</div>',
            '<div id="{id}-ariaMinText" class="' + Ext.baseCSSPrefix + 'hidden-clip">{ariaMinText}.</div>',
            '<div id="{id}-ariaMaxText" class="' + Ext.baseCSSPrefix + 'hidden-clip">{ariaMaxText}.</div>',
            '<div id="{id}-ariaDisabledDaysText" class="' + Ext.baseCSSPrefix + 'hidden-clip">{ariaDisabledDaysText}.</div>',
            '<div id="{id}-ariaDisabledDatesText" class="' + Ext.baseCSSPrefix + 'hidden-clip">{ariaDisabledDatesText}.</div>',
        '</div>',
        {
            firstInitial: function(value) {
                return Ext.picker.Date.prototype.getDayInitial(value);
            },
            isEndOfWeek: function(value) {
                // convert from 1 based index to 0 based
                // by decrementing value once.
                value--;
                var end = value % 7 === 0 && value !== 0;
                return end ? '</tr><tr role="row">' : '';
            },
            renderTodayBtn: function(values, out) {
                Ext.DomHelper.generateMarkup(values.$comp.todayBtn.getRenderTree(), out);
            },
            renderMonthBtn: function(values, out) {
                Ext.DomHelper.generateMarkup(values.$comp.monthBtn.getRenderTree(), out);
            }
        }
    ],

在模版的定义代码中,是通过“{%this.renderMonthBtn(values, out)%}”和“{%this.renderTodayBtn(values, out)%}”将月份按钮和今天按钮渲染到模版的,查看这两个方法的代码,都是使用Ext.DomHelper.generateMarkup来返回HTML代码的,而在传递的参数中,是通过values.$comp来获取组件的,再调用组件的getRenderTree方法来返回一些东西。具体的细节就不细究了,知道如何去使用就行了。
总结一下,要在模版中渲染Ext JS的组件的基本流程就是在beforeRender方法中创建组件,然后在模版中调用方法进行渲染。了解了这个基本流程,就好办了。
在试验阶段,先把日期选择器的代码全部复制过来,先根据这个实现了,再考虑从日期选择器继承的问题。日期选择器的代码复制后,先在beforeRender方法内创建3个数字字段。由于数字字段有些配置项是相同的,要把这些相同的配置项做成一个属性来调用,避免重复写这些代码,代码如下:

numberFieldDefaults: {
        allowBlank: false,
        allowDecimals: false,
        width: 92,
        maxLength: 2,
        autoStripChars: true,
        ariaRole: 'presentation',
        enforceMaxLength: true
    },

在配置项中,包括了数字字段不允许为空、不允许输入小数、宽度为92、字符最多是2个、自动去掉不符合要求的字符以及强迫实施最大长度策略。那个ariaRole是在定义月份按钮和今天按钮中都带有的,也就带上了。字段的宽度是根据实际效果试出来的。
接下来是创建数字字段,代码如下:

        me.hourField = new Ext.form.field.Number(Ext.apply({
            ownerCt: me,
            ownerLayout: me.getComponentLayout(),
            maxValue: 23,
            minValue: 0
        }, me.numberFieldDefaults));

        me.minuteField = new Ext.form.field.Number(Ext.apply({
            ownerCt: me,
            ownerLayout: me.getComponentLayout(),
            maxValue: 59,
            minValue: 0
        }, me.numberFieldDefaults));

        me.secondField = new Ext.form.field.Number(Ext.apply({
            ownerCt: me,
            ownerLayout: me.getComponentLayout(),
            maxValue: 59,
            minValue: 0
        }, me.numberFieldDefaults));

配置项ownerCt和ownerLayout也是定义月份按钮和今天按钮是带有的,就顺手了。这两个配置项是指定拥有者和拥有者布局的。余下的就是各数字字段允许的输入值了。
在使用公共配置项的时候,建议的做法是使用Ext.apply方法复制一份,不然创建的实例就都和公共的配置对象关联起来了,很容易出问题。
接下来是修改模版定义了,在显示日期的TABLE元素和渲染今天按钮之间的代码直接插入以下代码来渲染数字字段:

'<table role="grid" class="{baseCls}-footer" role="presentation" cellspacing="0" tabindex="0">',
'<tr>',
'<td style="width:102px;padding:5px">{%this.renderHourField(values, out)%}</td>',
'<td style="width:104px;padding:6px">{%this.renderMinuteField(values, out)%}</td>',
'<td style="width:102px;padding:5px">{%this.renderSecondField(values, out)%}</td>',
'</tr>',
'</table>',

代码中使用TABLE元素是为了布局方便,如果不喜欢,可自行修改为DIV元素,并设置相应的样式。这个TABLE元素是参考日期显示的TABLE元素写的。中间的单元格的宽度为104像素,是因为整个日期选择器的宽度是308,要平均分为三分分不了,只能将中间的单元格的宽度设置大点。
模版添加好了,还要为模版添加renderHourField、renderMinuteField和renderSecondField这三个方法,代码如下:

renderHourField: function (values, out) {
    Ext.DomHelper.generateMarkup(values.$comp.hourField.getRenderTree(), out);
},
renderMinuteField: function (values, out) {
    Ext.DomHelper.generateMarkup(values.$comp.minuteField.getRenderTree(), out);
},
renderSecondField: function (values, out) {
    Ext.DomHelper.generateMarkup(values.$comp.secondField.getRenderTree(), out);
}

下面的工作是在代码中搜索todayBtn属性,看看创建今天按钮后, 还有那些工作要做,是否时间的数字字段也需要做这些工作。从文件顶部开始搜索,跳过模版代码后,会转到getRefItems方法内,根据API中的描述,这个方法是用来创建组件树,用来实现组件查询的,这个不能少,需要将时间的数字字段添加到里面,代码如下:

    getRefItems: function () {
        var results = [],
            monthBtn = this.monthBtn,
            todayBtn = this.todayBtn,
            hourField = this.hourField,
            minuteField = this.minuteField,
            secondField = this.secondField;

        if (monthBtn) {
            results.push(monthBtn);
        }

        if (todayBtn) {
            results.push(todayBtn);
        }

        if (hourField) {
            results.push(hourField);
        }

        if (minuteField) {
            results.push(minuteField);
        }

        if (secondField) {
            results.push(secondField);
        }

        return results;
    },

继续搜索,跳过beforerender方法后,来到了selectToday方法,这里只是引用了今天按钮,可以跳过,继续搜索。接下来是fullupdate方法,这里只是禁用按钮,可以继续搜索。接下来是doDestroy方法,是用来销毁按钮的,这里要加入销毁操作,代码如下:

    doDestroy: function () {
        var me = this;

        if (me.rendered) {
            Ext.destroy(
                me.keyNav,
                me.monthPicker,
                me.monthBtn,
                me.nextRepeater,
                me.prevRepeater,
                me.todayBtn,
                me.hourField,
                me.minuteField,
                me.secondField,
                me.todayElSpan
            );
        }

        me.callParent();
    },

最后,就剩finishRenderChildren方法和syncDisabled方法了。根据方法的注释,finishRenderChildren方法是用来实现容器的布局操作的,根据对Ext JS的理解,容器的布局操作基本上就是用来调整子组件的位置和大小,估计和这个的工作差不多。再查下finishRender方法的API,发现该方法会以从上到下的顺序来访问组件树,该方法会在子组件运行该方法之前运行。不太好理解这个是干什么的。但后面那句话就容易理解多了,该方法会调用每一个组件的onReder方法,也就是说,这个是用来实现组件渲染的,因而必须在finishRenderChildren方法内添加时间的数字字段的代码:

finishRenderChildren: function () {
    var me = this;

    me.callParent();
    me.monthBtn.finishRender();
    me.hourField.finishRender();
    me.minuteField.finishRender();
    me.secondField.finishRender();
    if (me.showToday) {
        me.todayBtn.finishRender();
    }
},

这里有没有一个顺序问题?没细究,反正根据渲染顺序写就是了。
最后的syncDisabled方法是用来实现同步禁用,对于时间的数字字段不需要这东西。
现在,时间的数字字段显示是没问题了,但是没有初始值,在initComponent方法内,会看到以下代码:

me.value = me.value ? clearTime(me.value, true) : clearTime(new Date());

居然在设置初始值时,调用Ext.Date的clearTime方法把时间都抹去了,这当然是不行的,改:

me.value = me.value || new Date();

在文件内搜索clearTime方法,在setValue方法内找到了一个,这个要改:

    setValue: function (value) {
        // If passed a null value just pass in a new date object.
        this.value = value || new Date();
        return this.update(this.value);
    },

在selectToday方法有,该方法是用来实现选择日期后设置值的,要改:

me.setValue(new Date());

在fullupdate方法内也有,但是这里不能改。日期选择器在实现日期选择时,为了能确定单击某个元素后的日期值,特意将日期值(不包括时间)转换为(getTime方法的返回值)从1970年1月1日开始计算到日期值的时间之间的毫秒数,并作为元素的dataValue属性的值,这样,在获取到元素后,通过dataValue就可知道日期值了。如果把这里的clearTime清除,由于带有时间值,在最后的日期值判断上会出现问题,因而不建议修改。如果是完美主义者,也可以修改,但这需要点耐心,把需要修改的地方都修改好了,保证获取日期值不会出问题。

清理完clearTime方法后,时间值还是没有,这是因为没有给时间字段赋值。对于字段来说,一般的赋值都是在setValue方法里实现的,因而,在setValue方法内添加以下代码来实现时间字段的赋值:

        me.hourField.setValue(date.getHours());
        me.minuteField.setValue(date.getMinutes());
        me.secondField.setValue(date.getSeconds());

不过这招不好使,经测试,初始化的时候不会执行setValue方法。这只能耐心去研究初始化过程了。先从initComponent方法开始,这里只执行了initDisabledDays方法。在initDisabledDays方法内并没有执行初始化工作,也没有调用其他方法,因而不是在这里执行初始化的。接下来是beforeRender方法,渲染前执行的方法,这里只是执行了组件的初始化,不是这里。接下来是onRender方法,渲染时执行的方法,也没看到初始化值的操作。接下来是initEvents方法,在这里绑定了一堆事件后,调用了update方法。在update方法内调用了selectedUpdate和fullUpdate方法。根据注释,selectedUpdate方法是更新已选择的单元格的,并没有初始化值。在fullUpdate方法,刚才已经接触过了,是用来初始化日期列表,在最后还调用了me.monthBtn.setText方法来更新月份按钮的文本,看来,这里就是用来初始化值的地方了,把上面用来初始化时间的代码放这里试试,居然行了,那没问题了。

余下的问题就是返回值的问题了,日期下拉选择字段是通过监听日期选择器的select事件来获取返回值的,因而,在代码中,找到触发select事件的代码,并修改返回值就行了。在这里查找fireEvent会比查找select来得方便。
从文件顶部通过搜索fireEvent,首先会定义为handleTabKey方法,代码如下:

    handleTabKey: function(e) {
        var me = this,
            t = me.getSelectedDate(me.activeDate),
            handler = me.handler;

        // The following code is like handleDateClick without the e.stopEvent()
        if (!me.disabled && t.dateValue && !Ext.fly(t.parentNode).hasCls(me.disabledCellCls)) {
            me.setValue(new Date(t.dateValue));
            me.fireEvent('select', me, me.value);
            if (handler) {
                Ext.callback(handler, me.scope, [me, me.value], null, me, me);
            }
            me.onSelect();
        }
        // Even if the above condition is not met we have to let the field know
        // that we're tabbing out - that's user action we can do nothing about
        else {
            me.fireEventArgs('tabout', [me]);
        }
    },

从代码可以看到,它会先调用setValue方法将元素绑定的日期值设置为选择器的值,然后返回。由于dataValue值是并不包含时间字段的时间值,必须将这个加回去,修改后的代码如下:

    handleTabKey: function(e) {
        var me = this,
            t = me.getSelectedDate(me.activeDate),
            handler = me.handler,
            value ;

        // The following code is like handleDateClick without the e.stopEvent()
        if (!me.disabled && t.dateValue && !Ext.fly(t.parentNode).hasCls(me.disabledCellCls)) {
            //me.setValue(new Date(t.dateValue));
            value =  new Date(t.dateValue);
            me.setValue(new Date(value.getFullYear(),value.getMonth(), value.getDate(), me.hourField.getValue(), me.minuteField.getValue(), me.secondField.getValue() ));
            me.fireEvent('select', me, me.value);
            if (handler) {
                Ext.callback(handler, me.scope, [me, me.value], null, me, me);
            }
            me.onSelect();
        }
        // Even if the above condition is not met we have to let the field know
        // that we're tabbing out - that's user action we can do nothing about
        else {
            me.fireEventArgs('tabout', [me]);
        }
    },

主要修改地方是将调用setValue方法时,把时间字段的时间添加到日期中。为什么不在setValue方法里修改呢?这是因为,如果在setValue方法里修改,当调用setValue方法来赋值时,这时候的时间字段的值并不一定是赋值时的时间值,这样就会出现用当前的时间字段值替换了赋值时的时间值。

在测试时,会出现t.dateValue是undefined的错误,原因是getSelectedDate方法未能正确返回日期节点,而造成这个的主要原因是由于现在的日期值是带时间的,找节点时需要提供不带时间的日期值,因而需要在getSelectedDate方法内,将以下语句:

t = date.getTime()

修改为:

t = Ext.Date.clearTime(date,true).getTime(),

这样,就能正确找到对应日期的节点了。

继续搜索fireEvent,会在handleDateClick方法内找到select事件的触发,修改方式与修改handleTabKey一样。修改后,会发现选中的日期并没有高亮显示。经排查后,发现在selectedUpdate方法内,造成这个问题的主要原因与getSelectedDate方法的原因是一样的,将日期值清楚时间后,再调用getTime方法就没有问题了。

继续搜索fireEvent,会在selectToday方法内找到select事件,在这里,将“me.setValue(Ext.Date.clearTime(new Date()));”修改为不清理时间的日期值就行了。

在选择月份后,会重新设置一次日期,这里也需要修改,具体代码如下:

    onOkClick: function(picker, value) {
        var me = this,
            month = value[0],
            year = value[1],
            date = new Date(year, month, me.getActive().getDate(),me.hourField.getValue(), me.minuteField.getValue(), me.secondField.getValue());

        if (date.getMonth() !== month) {
            // 'fix' the JS rolling date conversion if needed 
            date = Ext.Date.getLastDateOfMonth(new Date(year, month, 1,me.hourField.getValue(), me.minuteField.getValue(), me.secondField.getValue()));
        }
        me.setValue(date);
        me.hideMonthPicker();
    },    

至此,日期时间选择字段就已经完成了。

作者:黄灯桥
原文:http://blog.csdn.net/tianxiaode/article/details/76944531