示例功能
通过树实现动态加载以及实现添加节点、删除节点、修改节点文本已经通过拖动移动节点等功能。
先创建树的表结构:
字段名称 | 类型 | 默认值 | 运行空 | 说明 |
ID | int | 否 | 自增主键 | |
Text | nvarchar(100) | 否 | 显示文本 | |
ParentID | int | 0 | 是 | 父节点的ID |
插入以下数据:
ID | Text | ParentNode |
1 | 图片 | 0 |
2 | 文档 | 0 |
3 | 视频 | 0 |
4 | 电视剧 | 3 |
5 | 报告 | 2 |
7 | 电影 | 3 |
8 | 活动 | 1 |
9 | 报表 | 2 |
10 | 旅游 | 1 |
实现代码
首先创建基本页面:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd"> <html> <head> <title>示例11-3 树的动态加载及节点维护</title> <link rel="stylesheet" type="text/css" href="../../Ext4/resources/css/ext-all.css"/> <script type="text/javascript" src="../../Ext4/bootstrap.js"></script> <script type="text/javascript" src="../../Ext4/locale/Ext-lang-zh_CN.js"></script> <style type="text/css"> /*在此添加样式代码*/ </style> </head> <body> <!--在此添加HTML代码--> <script type="text/javascript"> Ext.onReady(function(){ if(Ext.BLANK_IMAGE_URL.substr(0,4)!="data"){ Ext.BLANK_IMAGE_URL="./images/s.gif"; } //在此添加ExtJS代码 }); </script> </body> </html>
代码中已包含了样式、Ext JS的脚本文件和语言包。其实bootstrap.js会根据域名加载Ext JS的调试文件还是发布文件。如果域名地址是ip地址,会加载ext-all-debug.js文件,如果是域名,会加载ext-all.js。onReady函数也写好了,等下的脚本代码只要加入注释“在此添加ExtJS代码”就行了。
在开始之前,要先知道,树节点的编辑与使用Cellediting编辑Grid类似,不过有些地方需要小心,不然会很费劲。
在定义模型前,要清楚添加节点不能象Grid那样添加了,并且全部集中起来才保存,原因是用户在一个没有id的节点下添加了n层节点,然后将数据提交到服务器,那么,提交的节点的id都是0,服务器根据parentId根本就无法分清楚那个节点是那个节点的子节点,因而每新增一个节点,都必须第一时间到服务器获取实际id,返回后再添加到树。新增节点后,可使用模型的save方法保存数据,好处是,可监控节点的提交操作是否成功,如果成功,则添加到树,不成功则显示错误信息。而使用Store的sync方法,则需要先将记录添加到Store,才能使用,这不是好的选择。要调用模型的save方法,需要为模型配置Proxy,这样就不需要在Store配置Proxy了。目标明确,现在可以定义模型了:
Ext.define("TreeTest",{ extend:"Ext.data.Model", fields:["text", {name:"id",type:"int"}, {name:"parentId",type:"int"} ], validations: [{ type: 'presence', field: 'text' }], proxy: { type: 'ajax', api:{ read:'Tree.ashx?act=read', create:'Tree.ashx?act=add', destroy:'Tree.ashx?act=del', update:'Tree.ashx?act=edit' }, reader:{ messageProperty:"Msg", }, writer:{ type:"json", encode:true, root:"data", allowSingle:false } } })
因为现在的要求是一个个记录提交的,所以在模型中定义全部的操作api是最适合的。
字段的定义的字段最后与模型的默认字段保持一致,尤其是parentId,这样会省事很多。节点的移动会自动修改该值,只需要保存节点就行了,不需要自己处理。配置对象reader中的配置项messageProperty的作用是在服务器端返回的对象中,success为false时,提取错误信息的属性名称。配置对象writer还是习惯做法,使用data提取数据。
下面定义TreeStore:
Ext.create("Ext.data.TreeStore",{ id:'treeStore', model:"TreeTest", root:{text:"目录",id:-1,expanded:true} });
Proxy都在模型定义了,因而TreeStore没多少配置项。这里要注意,根节点的id不能设置为0,如果是0,Store会认为它是新节点,调用sync方法时候会将它提交,因为是整型数据,也不能定义为root这些字符串值,因而最好的办法是定义为-1这些不可能在数据库中出现的值。不过在写服务器端代码的时候要注意判断parentId为-1时,要将其修改为0。在这里没把根节点隐藏是考虑到要在根节点下添加节点,除非多添加一个增加按钮用来添加根节点下的节点,不然你很难判断到底是在那个位置添加节点,或通过弹出对话框也可解决这个问题,不过笔者觉得这样更简便,只要禁止用户编辑根节点就行了。
下面定义TreePanel:
Ext.create("Ext.tree.Panel",{ title:"树的动态加载及节点维护", 200, height:300, hideHeaders:true, plugins:[{ptype:"cellediting", listeners:{ beforeedit:function(e){ if(e.record.isRoot()) return false; } } }], renderTo:Ext.getBody(), store:"treeStore", tbar:[ {text:"增加",id:"add",handler:function(){ }}, {text:"删除",id:"delete",disabled:true,handler:function(){ }}, "|", {text:"刷新",handler:function(){ this.up("treepanel").store.load(); }} ], columns:[ {xtype:"treecolumn",dataIndex:"text",flex:1, field:{allowBlank:false} } ], viewConfig:{ toggleOnDblClick:false, plugins: { ptype: 'treeviewdragdrop' }, listeners:{ refresh:function(){ this.select(0); this.focus(0); } } }, listeners:{ selectionchange:function(view,rs){ Ext.getCmp("delete").setDisabled(rs.length==0); } } })
树与Grid同源的好处就是可使用CellEditing插件编辑节点,而且定义也基本一样。不过要注意的问题很多。首先,因为定义了配置项columns,所以会显示列标题,因而要定义hideHeaders配置项将其隐藏。配置项columns的定义是必须的,不然你无法加入编辑框,而且要定义配置项flex为1,让列使用整个面板的宽度作为其宽度。为了禁止编辑根节点,因而在Cellediting的配置对象中监听了beforeedit事件,如果要编辑的节点是根节点,直接返回false可中止进入编辑状态。在视图的配置对象中,toggleOnDblClick为false这个配置项很关键,其默认是为true的,因而默认情况下,双击节点会展开或折叠节点,这样,你就必须使用单击进入编辑状态,但这样很麻烦,例如你要选择某个节点,单击它,成编辑状态了,只能退出后,再挑好位置单击才能选择。因而,最好的选择是将该值设置为false,空出双击操作给编辑操作,这样就方便多了。要允许通过拖动改变节点位置,只要加入TreeViewDragDrop插件就可以了,它会在视图渲染时设置视图的dragZone和dropZone配置项,从而在视图实现拖放操作。事件refresh的代码应该清楚其作用了,它会设置默认选择第一个节点,并将焦点移动到视图,然后通过键盘导航。
现在考虑增加按钮的操作,也不难,先通过选择模型获取父节点,然后获取其id作为新节点的parentId值,关键是调用save方法后的处理,如果保存成功,则要看父节点的状态是不是展开状态,如果不是,则要展开它,让它去下载数据,这样自然会把新节点也加载下来,如果已经展开,则直接用appendChild方法将新节点追加到父节点下。如果发生错误,则显示错误信息。思路想好后,代码也就容易了:var tree=this.up("treepanel"), parent=tree.getSelectionModel().getSelection()[0]; if(! parent){ parent=tree.store.tree.root; } var rec=new TreeTest({ text:"新节点", id:"", parentId:parent.data.id }); rec.save({ parentNode:parent, success:function(rec,opt){ if(opt.parentNode.isExpanded()) opt.parentNode.appendChild(rec); else opt.parentNode.expand(); }, failure:function(e,op){ Ext.Msg.alert("发生错误",op.error); }, scope:tree });
因为要先从服务器添加数据后才能追加节点,所以新节点文本只能先固定为“新节点”,然后再让用户通过编辑的方法修改,除非使用弹出对话框,让用户先输入节点文本,再保存,最简单的办法就是使用MessageBox对象的prompt方法,这个可根据项目需求确定使用那个办法,不难。这里没有实现象Grid那样,插入后记录,在记录位置显示编辑框,主要原因是,要去找记录的行位置,工作量太大,笔者比较懒,有兴趣自己研究一下。当服务器端返回错误信息的时候,会将Msg属性中的信息记录到Operation对象的error属性中,因而直接将其显示出来就行了。
下面考虑编辑的问题,这里没有按钮操作,主要是考虑如何保存修改过的数据,简单的办法是和Grid一样,通过一个保存按钮保存,不过在树中的单元格左上角会显示一堆的红色小三角,不太好,还是编辑一个保存一个比较好。不过,最大的问题来了,TreeStore的autoSync配置项居然不起作用,只能通过代码实现保存了。Cellediting对象在编辑完成后,都会触发edit事件,而这正是最好的保存时机,因而,现在要为Cellediting对象的配置对象添加一个edit事件:edit:function(edit,e){ e.record.save({ success:function(rec,opt){ opt.records[0].commit(); }, failure:function(e,op){ op.records[0].reject(); Ext.Msg.alert("发生错误",op.error); } }); }
如果保存成功,使用commit方法确认修改,不然使用reject方法恢复原来的值,这样就可把那个编辑的小图标去掉了。
删除操作也是一个比较考验人的操作,例如,删除一个节点,其层次很深,那么服务器端代码就要做很多次的递归操作,效率会很低,要想不递归删除,办法有3个,第一个是让用户自己先确保没有子节点,才允许删除节点,这办法比较笨,但笔者认为比较实用;第二个办法是确保层数是在有限的范围的;第3个办法是添加附加字段,不过这也是要确保在有限层内才行的。最好的办法还是不要搞无限层的树,用户当然是希望能无限层的,但是如文件目录一样,超过10层以上你就会烦了,笔者估计已经开始国骂了。本示例既定方针,让用户确定没有子节点才允许删除节点,代码如下:var tree=this.up("treepanel"); var rs=tree.getSelectionModel().getSelection(); if(rs.length>0){ rs=rs[0]; if(rs.data.root){ Ext.Msg.alert("删除节点","根节点不允许删除!"); return; } if(rs.isExpandable() || rs.hasChildNodes()){ Ext.Msg.alert("删除节点","请先删除所有子节点,再删除该节点!"); return; }else{ var content="确定删除节点:"+rs.data.text+"?"; Ext.Msg.confirm("删除节点",content,function(btn){ if(btn=="yes"){ var rs=this.getSelectionModel().getSelection(); if(rs.length>0){ rs=rs[0]; rs.remove(); this.store.sync(); this.view.select(0); this.view.focus(false); } } },tree) } }
根节点是不能删除的,这个不用说。节点是折叠状态,或者有子节点,不允许删除,直接返回。如果节点可以删除,提示用户是否真的删除,如果是,调用remove方法删除节点,并立刻进行同步。
通过拖动移动节点这个操作,比较难的是如何处理数据的保存,如果在TreeStore对象的beforemove事件中先保存,则要先挂起代码,然后调用同步方法,在同步后的事件根据成功与否返回ture或者false,确认是否移动,比较复杂。简单的办法,就是允许移动,如果保存不成功,刷新显示,成功则什么也不用做,这样真的很简单,代码如下:move:function(tree,node){ var me=this; node.save({ failure:function(e,op){ Ext.Msg.alert("发生错误","保存移动时发生错误,现在要刷新树!<br/>" +"错误原因:"+op.error,function(){ this.load(); },me); }, scope:me }); }
回调的success方法也省了,只需要failure方法,错误时,等用户关闭提示信息窗口后,调用load方法刷新根节点就好了。
现在开始写服务器端代码,老样子,先根据参数act调用不同的方法。先处理Read方法,因为动态树是根据节点加载数据的,因而会将节点id提交到服务器端,默认提交参数是node,因而在服务器端可通过node获取父节点的id。如果不想使用node作为提交参数,可在TreeStore的配置对象中定义nodeParam配置项来改变提交参数,例如定义了nodeParam为id,则服务器端要通过id获取父节点的id。Read方法的具体代码不难,可自己阅读相关文件,再此就不列了。
在完成Add、Edit和Delete方法前,与Grid一样,需要先定义一个对象用来将JSON数据转换为对象进行操作:C# public class Node { public int id { set; get; } public string text { set; get; } public int parentId { set; get; } public int index { set; get; } public int depth { set; get; } } JAVA public class Node { int id,parentId,index,depth; String text; Boolean checked; }
因为模型的运作模型要求提交的数据字段与返回的数据的字段一样才会确认数据更新,因而对象的定义最好是与提交数据字段保持一致,尤其是树的字段,因为有附加字段,所以定义不能与数据库的字段一致。不同的语言,麻烦各不同,在C#中,checked居然是关键字,因而不能作为类的属性,所以返回的数据还得重新组合一次,还好,这里checked值都是null值,容易处理,如果提交上来的是true或者false,不确定的值,那就麻烦大了,只能在客户端监测到checked值变化后,修改一个自定义字段的值,然后在服务器端,根据该提交值,返回时再造checked的值了。在JAVA中,boolean不能为null,还好Boolean可以,问题不大。
然后要定义一个数据转换方法ProcessData,代码如下:C# private List<Node> ProcessData(string data) { List<Node> nodes = new List<Node>(); nodes = JsonConvert.DeserializeObject<List<Node>>(data); return nodes; } JAVA protected Node[] ProcessData(String data){ Gson gson = new Gson(); Node[] nodes =gson.fromJson(data, Node[].class); return nodes; }
这个问题不大,在第10章已经实现过类似代码。如果是在项目中,最好是写一个通用接口,然后调用,这样只要定义好类就可以调用了,不用每个处理都写一个ProcessData方法。
Add、Edit和Del方法,与10.7.5节的内容差别不大,在此就不详细说明了。Add方法的重点是获取数据插入数据库后的自增的id值,然后再返回,这个对熟悉数据库开发的应该很熟悉了。这3个处理方法中,共同要注意的是,如果parentId值是-1,记得保存到数据库时,要将其转换为0,Read方法则需要将0转换为-1。不过这是笔者的疏忽,直接在ParentId字段以-1作为根节点下的节点就不用这么麻烦了,如果你项目是使用相同的数据结构,可考虑这样使用。
还要注意,C#的Add方法和Edit,要重新构造JSON对象,把checked属性加入到返回的数据中。而JAVA中,因为GSon对象会忽略掉值为null的数据,因而需要使用以下语句定义Gson对象:Gson gson = new GsonBuilder().serializeNulls().create();至此,示例就完成了。
页面效果
在浏览器打开页面,然后单击增加一个节点,然后将“新节点”修改为“音乐”,最后将其拖动到文档下,将看到如下图所示的效果。