WinForm实现三态树控件:权限树级联勾选与半选状态详解
2026/9/16 8:39:33 网站建设 项目流程

做后端管理系统的朋友应该都遇到过这种需求:在一个树形结构里给用户分配权限,父子层级有好几层,节点前面要放复选框,父节点选中了子节点跟着选,"子节点选了一部分父节点要显示半选"。这个需求在WinForm里实现起来没有看起来那么简单,因为给TreeView加上CheckBoxes = true只是第一步——原生控件只提供选中和未选中两种状态,半选状态得靠我们自己想办法。

这篇文章会从零开始,带你实现一个带复选框的三态树控件,包含完整的C#代码、级联勾选算法、半选状态的底层实现原理,以及我在实际项目里踩过的坑和最终沉淀的解决方案。适合刚接触WinForm的初级开发者参考,也适合想让权限树交互更专业的中级开发者直接抄作业。

1. 项目背景:权限树里的"半选"难题

1.1 三态树到底长什么样

先定义一个场景。假设你在一套进销存系统里做"角色权限分配"界面,左侧是一棵公司组织架构树:

公司 ├─ 研发部 │ ├─ 后端开发组 │ ├─ 前端开发组 │ └─ 测试组 └─ 市场部 ├─ 品牌组 └─ 渠道组

现在要给某个角色分配"可访问的部门",最直观的交互就是每个节点前放一个复选框,让用户打勾。问题来了:

  • 如果把"研发部"勾上,"后端开发组""前端开发组""测试组"要不要自动跟着勾?如果不联动,用户得手动点三次,体验很差。
  • 如果只勾了"后端开发组"和"前端开发组","研发部"前面的复选框应该显示什么状态?显示不勾选吧,但明明有两个子节点是勾上的;显示勾选吧,还有一个子节点没勾。

这个时候就需要第三种状态:半选(也常被叫做Intermediate、Partially checked、Indeterminate)。

这种"父子联动 + 半选"的树形控件,就是三态树。它在权限管理、菜单配置、数据授权、商品类目选择等场景里极其常见,可以说是WinForm后台管理系统里最经典的一个控件需求。

1.2 原生TreeView的CheckBoxes为什么不够用

WinForm自带的TreeView控件,设置CheckBoxes = true之后,每个节点前面会出现一个复选框,用户点击即可切换选中状态。但它有两个硬伤:

  • 没有半选态:复选框只有"选中"和"未选中"两种表现,你没法让一个父节点显示"部分子节点被选中"。
  • 父子节点勾选完全独立:勾选父节点不会自动联动子节点,子节点状态变了父节点也不会自动更新。用户勾了"研发部"但忘了勾里面的子部门,保存数据后权限分配就是残缺的。

所以网上搜"WinForm TreeView 三态树",能看到大量帖子问"怎么让父节点显示半选""为什么子节点不联动",根源就是这两个硬伤。原生控件不提供现成方案,只能自己动手造轮子。

2. 动手前的设计:状态建模与联动规则

2.1 三种状态如何定义

在写代码之前,先把状态建模想清楚。我习惯用一个枚举来定义三态:

/// <summary> /// 节点勾选状态 /// </summary> public enum TriState { /// <summary>未勾选</summary> Unchecked = 0, /// <summary>已勾选</summary> Checked = 1, /// <summary>半选:子级部分被勾选</summary> Mixed = 2 }

枚举很简单,但有一个特别容易踩坑的地方:半选状态既不是逻辑上的"选中",也不是"未选中",它是独立的状态。这句话听着像废话,实际写代码的时候很多人就栽在这里——他们把半选节点当成"选中"处理,把Checked属性设成true,最后收集结果时把半选节点也当成选中项收集进去了,数据直接错乱。

2.2 父子联动的完整规则

三态树的交互规则,业界基本是统一套路,我先用文字描述清楚,后续代码严格按这个规则实现:

  1. 用户勾选一个节点,它下面所有子节点全部同步为同一状态。勾上就全勾上,取消就全取消。
  2. 子节点状态变化后,父节点的状态由它的直接子节点决定:
    • 所有直接子节点都勾选 → 父节点勾选。
    • 所有直接子节点都没勾选 → 父节点不勾选。
    • 一部分勾选、一部分没勾选,以及存在半选子节点的情况 → 父节点半选。
  3. 父节点状态变化后,继续向上递归应用规则2,一直到根节点。

这里有一个初学者特别容易忽略的细节:判断父节点状态时,必须把"半选"单独考虑进去。比如一个父节点有两个子节点,子节点A是半选,子节点B是未勾选,如果只统计child.Checked的个数,会数出"1个被勾选",于是父节点被判成半选——结果碰巧是对的。但如果两个子节点A、B都是半选,统计出来是"2个被勾选",父节点就会错误地显示为"完全勾选"。所以严格的写法必须用三态值去统计,不能只看Checked布尔值。

3. 完整编码:三态树控件的实现过程

3.1 第一步:通过Win32 API控制复选框的第三种显示状态

先解决"半选态怎么显示出来"的问题。TreeView的复选框本质上由一个状态图像列表(StateImageList)控制,状态图像索引1表示未选中,2表示选中。好消息是TreeView底层还预留了索引3,在大多数Windows版本的主题下可以显示为"半选"的样式,只是WinForm没有公开这个能力,需要借助Win32 API手动设置。

封装一个辅助类:

using System; using System.Runtime.InteropServices; using System.Windows.Forms; /// <summary> /// TreeView原生复选框三态辅助类 /// </summary> public static class NativeTreeViewHelper { [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, ref TVITEM lParam); private const int TVIF_STATE = 0x8; private const int TVIS_STATEIMAGEMASK = 0xF000; private const int TVM_SETITEM = 0x110D; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private struct TVITEM { public int mask; public IntPtr hItem; public int state; public int stateMask; public string pszText; public int cchTextMax; public int iImage; public int iSelectedImage; public int cChildren; public IntPtr lParam; } /// <summary> /// 设置节点复选框的显示状态 /// </summary> /// <param name="treeView">承载节点的TreeView</param> /// <param name="node">目标节点</param> /// <param name="state">目标状态</param> public static void SetNodeTriState(TreeView treeView, TreeNode node, TriState state) { TVITEM item = new TVITEM(); item.hItem = node.Handle; // 节点句柄 item.mask = TVIF_STATE; // 我们要操作的是状态 item.stateMask = TVIS_STATEIMAGEMASK; // 只改状态图像部分 // 状态图像索引:1=未选,2=选中,3=半选 int imageIndex; switch (state) { case TriState.Checked: imageIndex = 2; break; case TriState.Mixed: imageIndex = 3; break; default: imageIndex = 1; break; } // 关键点:state 的高4位存放状态图像索引 item.state = imageIndex << 12; SendMessage(treeView.Handle, TVM_SETITEM, IntPtr.Zero, ref item); } }

代码不复杂,说几个要点。imageIndex << 12这个左移,是因为TVIS_STATEIMAGEMASK的值是0xF000,正好对应state字段的高4位,Windows约定状态图像的索引放在这个位置。TVM_SETITEM这条消息负责把一个TVITEM结构写回控件,让指定节点的状态图像立即刷新。

注意:索引3在大部分机器上能正常显示为"半选"方块,但如果你用的是定制主题,或者发现显示效果太淡、看不清,别硬扛,可以自己做三张状态图片(未选/选中/半选)赋值给treeView1.StateImageList,然后用索引1/2/3去引用它们,视觉上完全可控。

3.2 第二步:搭建主窗体并组织三态状态管理

接下来是核心部分。我建议在窗体里先定义两个"基础设施",它们是整个三态树能稳定工作的前提:

public partial class FrmTriStateTree : Form { // 标记是否处于程序内部批量更新状态 private bool _internalUpdating = false; // 记录哪些节点处于“半选”状态 private readonly HashSet<TreeNode> _mixedNodes = new HashSet<TreeNode>(); public FrmTriStateTree() { InitializeComponent(); } // 其余代码见下文 }

_internalUpdating这个字段的作用,等你在第3.3节看到AfterCheck事件时会明白,它是防止事件递归风暴的总闸。_mixedNodesHashSet存储半选节点,它的作用有二:一是RecalcParentState统计父节点状态时需要知道"哪些子节点是半选",二是SetNodeTriState设置状态时需要维持集合的增删。

然后是"设置节点三态"的核心方法,它把"三态逻辑值""Checked属性""视觉呈现"三件事统一管理起来:

/// <summary> /// 设置节点三态,同时更新Checked属性与视觉呈现 /// </summary> private void SetNodeTriState(TreeNode node, TriState state) { switch (state) { case TriState.Checked: _mixedNodes.Remove(node); node.Checked = true; NativeTreeViewHelper.SetNodeTriState(treeView1, node, TriState.Checked); break; case TriState.Unchecked: _mixedNodes.Remove(node); node.Checked = false; NativeTreeViewHelper.SetNodeTriState(treeView1, node, TriState.Unchecked); break; case TriState.Mixed: _mixedNodes.Add(node); // 注意这里:半选节点在逻辑上保持 Checked=true, // 这样用户点击它时,TreeView会把true切换为false, // 后续联动逻辑就会执行“取消勾选整棵子树”的操作。 node.Checked = true; NativeTreeViewHelper.SetNodeTriState(treeView1, node, TriState.Mixed); break; } }

这个方法的注释我特意标了一句话:半选节点的Checked保持true。很多教程不解释这里,读者把代码拷过去后发现半选状态显示了,但一点击节点行为特别怪。原因就是半选节点的Checked属性没有跟视觉状态对齐。让它保持为true,点击事件的切换方向才符合直觉:半选 → 点击 → 全部取消。同时,后续收集结果时也更容易判断哪些节点是半选(在_mixedNodes里的就是)。

3.3 第三步:实现级联勾选与父级回算

完成设施搭建后,就到了最关键的事件处理环节。TreeView有一个专门的事件叫AfterCheck,它在复选框勾选状态变化后触发。我们在这里写级联逻辑:

private void treeView1_AfterCheck(object sender, TreeViewEventArgs e) { // 程序内部修改触发的AfterCheck,不再处理 if (_internalUpdating) return; // 键盘或鼠标操作触发的AfterCheck,Action 不是 Unknown if (e.Action == TreeViewAction.Unknown) return; _internalUpdating = true; try { // 1. 先更新当前节点自身的三态(清除可能的半选标记) SetNodeTriState(e.Node, e.Node.Checked ? TriState.Checked : TriState.Unchecked); // 2. 把当前节点状态同步到所有子节点 CascadeCheckState(e.Node, e.Node.Checked); // 3. 从当前节点向上回算父节点状态 RecalcParentState(e.Node); } finally { _internalUpdating = false; } }

AfterCheck这个事件有个反直觉的行为:不仅用户点击复选框会触发它,代码里给Checked属性赋值也会触发它。两者的区别在e.Action:用户点击触发的ActionTreeViewAction.ByMouseTreeViewAction.ByKeyboard,代码赋值触发的ActionTreeViewAction.Unknown。所以第3行的判断就是为了排除程序自己赋值导致的事件重入。

不过仅靠这一层还不够。在SetNodeTriState里我们给父节点的Checked赋值时,虽然父节点自己会被Unknown判断拦下,但CascadeCheckState里子节点的Checked赋值同样会触发各自的AfterCheck。这个时候第一行的_internalUpdating判断就发挥了作用:只要它被置为true,所有嵌套进来的AfterCheck都会被拦截,不会形成递归风暴。

接着看子级级联的代码:

/// <summary> /// 把某个节点的勾选状态递归同步到所有子级 /// </summary> private void CascadeCheckState(TreeNode node, bool isChecked) { foreach (TreeNode child in node.Nodes) { SetNodeTriState(child, isChecked ? TriState.Checked : TriState.Unchecked); if (child.Nodes.Count > 0) { CascadeCheckState(child, isChecked); } } }

这段逻辑很直白:遍历直接子节点,把子节点统一设置为Checked或Unchecked,如果子节点还有下级就递归。注意这里调用的是SetNodeTriState而不是直接给child.Checked赋值,因为我们要确保所有子节点的半选标记被同步清理干净——如果一个子节点之前是半选(存在_mixedNodes里),现在被级联成全勾选,它的半选标记必须移除。

然后是父级回算算法:

/// <summary> /// 从某个节点开始向上回算父节点状态 /// </summary> private void RecalcParentState(TreeNode node) { TreeNode parent = node.Parent; if (parent == null) return; int checkedCount = 0; bool hasMixed = false; int totalCount = parent.Nodes.Count; foreach (TreeNode child in parent.Nodes) { if (_mixedNodes.Contains(child)) { hasMixed = true; // 有半选子节点 } else if (child.Checked) { checkedCount++; // 完全勾选的子节点 } } TriState newState; if (hasMixed || (checkedCount > 0 && checkedCount < totalCount)) { newState = TriState.Mixed; } else if (checkedCount == totalCount) { newState = TriState.Checked; } else { newState = TriState.Unchecked; } SetNodeTriState(parent, newState); // 继续向上回算 if (parent.Parent != null) { RecalcParentState(parent); } }

这里的核心是统计逻辑。hasMixed单独拎出来,就是为了避免前面提到的"两个半选子节点被统计成两个勾选"的bug。只要存在半选子节点,父节点就一定不是"全勾选"。

用生活化类比解释一下这个算法。想象你是年级主任,想判断一个班级的"整体出勤情况",你只看各小组的汇报:只要有一个小组是"部分出勤",班级就只能算"部分出勤";只有所有小组都全员到齐,班级才能算"全员出勤";如果所有小组都没人,班级才是"无人出勤"。RecalcParentState干的就是年级主任的活。

3.4 第四步:收集选中结果与初始化回显

三态树做出来不是光看的,最终要把用户的勾选结果保存。怎么收集?这里有一个重要原则:只认叶子节点。因为父节点的勾选/半选状态是派生的,如果把它也存下来,下次回显时会出现状态冲突。

/// <summary> /// 获取所有被勾选的叶子节点列表(返回节点Name) /// </summary> public List<string> GetCheckedLeafNames() { List<string> result = new List<string>(); CollectCheckedLeaves(treeView1.Nodes, result); return result; } private void CollectCheckedLeaves(TreeNodeCollection nodes, List<string> result) { foreach (TreeNode node in nodes) { if (node.Nodes.Count == 0) { if (node.Checked) { result.Add(node.Name); } } else { CollectCheckedLeaves(node.Nodes, result); } } }

注意:如果你有"父节点即使全选了也要单独保存"的业务要求,比如父节点本身也是一个可授权的菜单项,那就在else分支里加一个判断,把全选的父节点也加入结果。但默认建议只保存叶子,这样回显逻辑最简单。

回显的逻辑正好相反:先按叶子节点的NameChecked设置好,然后从最深层开始向上重新计算所有父节点的状态:

/// <summary> /// 恢复勾选状态(从数据库/配置文件加载) /// </summary> public void RestoreCheckedNodes(IEnumerable<string> checkedNames) { _internalUpdating = true; try { treeView1.BeginUpdate(); HashSet<string> nameSet = new HashSet<string>(checkedNames); // 1. 只设置叶子节点 SetLeafState(treeView1.Nodes, nameSet); // 2. 从最深层开始向上回算所有父节点 foreach (TreeNode node in treeView1.Nodes) { RecalcSubtree(node); } treeView1.EndUpdate(); } finally { _internalUpdating = false; } } private void SetLeafState(TreeNodeCollection nodes, HashSet<string> nameSet) { foreach (TreeNode node in nodes) { if (node.Nodes.Count == 0) { node.Checked = nameSet.Contains(node.Name); } else { SetLeafState(node.Nodes, nameSet); } } } /// <summary> /// 递归处理整棵子树:先把子节点都处理好,再回算当前节点 /// </summary> private void RecalcSubtree(TreeNode node) { foreach (TreeNode child in node.Nodes) { RecalcSubtree(child); } if (node.Nodes.Count == 0) return; // 叶子节点无需回算 int checkedCount = 0; bool hasMixed = false; int totalCount = node.Nodes.Count; foreach (TreeNode child in node.Nodes) { if (_mixedNodes.Contains(child)) hasMixed = true; else if (child.Checked) checkedCount++; } if (hasMixed || (checkedCount > 0 && checkedCount < totalCount)) SetNodeTriState(node, TriState.Mixed); else if (checkedCount == totalCount) SetNodeTriState(node, TriState.Checked); else SetNodeTriState(node, TriState.Unchecked); }

RecalcSubtree是后序遍历:先递归处理所有子节点,再回算当前节点。这样能确保每个父节点统计时,它的直接子节点状态都已经是最新的。对所有根节点都执行一遍,整棵树的父节点状态就全部正确了。

4. 细节打磨与性能优化

4.1 大数据量节点怎么扛住

权限树的规模通常不大,几十上百个节点,上面的代码闭着眼跑。但如果你想拿它做"全公司组织架构"或者"全量商品分类"控件,几千上万个节点的时候,有几个优化必须做。

第一,构建树的时候用BeginUpdate()EndUpdate()包住。这两兄弟能暂停控件的重绘,让你把几千个节点全部塞进去之后再一次性刷新。没有这个,每Add一个节点控件就重绘一次,直接卡成幻灯片。

private void BuildTree() { treeView1.BeginUpdate(); treeView1.Nodes.Clear(); _mixedNodes.Clear(); // 别忘了清理半选标记集合 // ... 创建节点、Add到树上 ... treeView1.EndUpdate(); }

第二,能不展开就先别展开ExpandAll()在小树没什么,大树是性能杀手。改成节点在被展开时才加载子节点,这是懒加载的经典做法:

private void treeView1_BeforeExpand(object sender, TreeViewCancelEventArgs e) { // 如果子节点数量为1且该节点是占位符,说明还没真正加载,去数据库查 if (e.Node.Nodes.Count == 1 && e.Node.Nodes[0].Tag == "LOAD_PLACEHOLDER") { e.Node.Nodes.Clear(); LoadChildrenFromDb(e.Node); } }

占位符方案很好理解:在叶子节点下挂一个假的子节点,TreeView就会显示加号,等用户点开加号时再查数据库填充真实数据。

第三,半选状态在显示时调用Win32 API是不可避免的,但在批量回显的场景下,RecalcSubtree会对每个节点调用SetNodeTriState,每个SetNodeTriState都会调用一次SendMessage。上万节点时会有压力。如果遇到这种情况,确保所有SendMessage都发生在BeginUpdate/EndUpdate的保护范围内,实测效果会好很多。更彻底的方案是直接替换StateImageList里的图标,把索引3换成自定义半选图标,然后批量赋值。

4.2 防止递归风暴与误触发的技巧

这一节我觉得是整个实现里最值钱的经验,因为三态树的bug十有八九出在事件重复触发上。

AfterCheck的反直觉行为刚才说过了:代码里设置Checked属性也会触发它。如果不加保护,会发生这种情况——用户勾选了父节点,级联代码给第一个子节点赋值Checked=true,触发了AfterCheck,事件处理器又去给它的子节点赋值,又触发AfterCheck……轻则重复计算浪费性能,重则递归死循环直接栈溢出。

我们的双层保护:

if (_internalUpdating) return; // 第一道:全局更新锁 if (e.Action == TreeViewAction.Unknown) return; // 第二道:程序赋值识别

_internalUpdatingtry/finally包裹,不管中间抛不抛异常都能正确复位。这两道拦网配合使用,实测很稳。我见过一些项目只用了e.Action == TreeViewAction.Unknown这一层,结果在特定场景下还是会出问题——因为某些UI自动化库触发的事件Action可能也是Unknown,或者你在重置数据时手动去调用了级联函数。

还有一个小坑:不要在AfterCheck里做耗时操作。这个事件绑定在UI线程上,而且会频繁触发,如果你在事件里访问数据库或者写日志,界面会卡得很难看。需要保存结果时,建议放到按钮点击事件或者延迟保存。

4.3 界面美化与第三方UI库

三态树的基础功能实现了,可原生TreeView的颜值确实不咋地。我在项目里给树做过一轮美化,分享几个低成本高回报的设置:

treeView1.FullRowSelect = true; // 整行选中,视觉更清爽 treeView1.ShowLines = false; // 去掉虚线,更现代 treeView1.HideSelection = false; // 失焦时保持选中高亮 treeView1.DrawMode = TreeViewDrawMode.OwnerDrawText; // 自绘文字 treeView1.ItemHeight = 28; // 行高一点,更舒适

开了OwnerDrawText之后,在DrawNode事件里可以自定义字体颜色、节点图标、选中背景色:

private void treeView1_DrawNode(object sender, DrawTreeNodeEventArgs e) { if (e.Node == null) return; Color textColor = treeView1.ForeColor; if ((e.State & TreeNodeStates.Selected) != 0) { textColor = Color.White; // 选中时用白色文字 } TextRenderer.DrawText(e.Graphics, e.Node.Text, e.Node.NodeFont ?? treeView1.Font, e.Bounds, textColor, TextFormatFlags.VerticalCenter | TextFormatFlags.Left); }

配合主题色,能做出很接近网页后台管理系统的效果。关于"winform界面美化"和"c# winform主题实现的方法"这两个话题,其实核心思路是统一的:把颜色、字体、间距等视觉参数统一收敛到一个主题配置类里,运行时按配置刷新控件外观。TreeView只是其中一个受益者。

如果你不想跟Win32 API纠缠,也可以直接引入第三方UI库,比如SunnyUI、AntDUI、ReaLTaiizor都自带支持三态勾选的树形控件,省去很多底层功夫。但我的建议是:如果你的项目就这一处树控件,自己封装也不难;如果全系统有大量树形交互,上成熟组件库更划算——毕竟组件库还要考虑主题切换、高DPI适配、键盘导航等一堆边界问题。

5. 常见问题与排查清单

5.1 半选状态不显示或显示成"淡灰色"怎么办

半选状态靠Win32 API设置状态图像索引3,但索引3在不同系统主题下的显示效果不一样。Windows 10/11默认主题下通常是个深色小方块,勉强能看;老旧主题下可能变成一个非常淡的灰框,视力不好根本看不出。

排查思路分两步。第一步,确认你设置的是索引3而不是别的,把代码里的imageIndex打印出来,或者临时固定为2/1看显示变化。第二步,如果确实是主题问题,换成自定义StateImageList——自己准备三张图片(未选/选中/半选),给treeView1.StateImageList赋值,然后Win32 API里的索引1/2/3就映射到你的图片,显示效果完全可控。

5.2 级联勾选时死循环或者状态错乱

死循环基本可以断定是_internalUpdating没有生效,检查三件事:

  1. 事件处理器里是否在finally中复位了_internalUpdating
  2. 是否所有程序内赋值的路径都经过了_internalUpdating = true的保护(比如初始化、回显,还有别的方法里直接给Checked赋值的地方)。
  3. RecalcParentState里有没有可能无限向上递归——只有parent == null时递归终止。如果你在代码里不小心把某个节点的Parent指向了自己(虽然不常见),会直接炸栈。

状态错乱(比如父节点明明只选了一个子节点却显示全选)要重点检查统计逻辑。我前面专门强调的"两个半选子节点被当成了两个勾选"就是最常见的错乱原因。确保每次统计都使用三态值,而不是只看Checked布尔值。

5.3 勾选状态无法持久化或回显不对

回显不对,十有八九是保存和恢复用了不同的"键"。我建议统一用叶子节点的Name属性作为权限编码保存到数据库,恢复的时候也只设置叶子节点的Checked,然后交给RecalcSubtree去生成父节点状态。不要试图把父节点的三态值也存进数据库——那是自找麻烦,因为三态是派生数据,重新计算永远比存储和同步更可靠。

如果确实需要按节点路径保存(比如节点Name不唯一),注意FullPath依赖PathSeparator,路径里别用特殊字符,恢复时用treeView1.Nodes.Find()按Name或Key查找会更稳妥。

5.4 其他容易忽略的边界问题

  • _mixedNodes里保存的是TreeNode引用,如果调用treeView1.Nodes.Clear()清空树后没有调用_mixedNodes.Clear(),旧节点引用会一直留在HashSet里,造成内存泄漏。
  • 键盘操作:TreeView默认支持空格键切换复选框,支持上下方向键移动焦点,这些行为在三态树里依然生效,不用额外处理。但注意半选节点按空格后的行为——它会被视为"已选中"切换为"未选中",这个逻辑跟上文节点Checked保持一致。

最后说一点我的体会。三态树这个需求看起来不大,但真正动手你会发现它牵扯到Win32互操作、事件重入、状态建模、性能优化好几个方向。我最初的第一版实现也栽过跟头:半选节点的Checked属性跟视觉状态不一致,导致收集结果把半选节点混进选中列表,后来把三态值和Checked属性统一封装在SetNodeTriState里才彻底解决。

如果你要把这套逻辑真正用到项目里,我的建议是封装成一个UserControl,把SetNodeTriStateRecalcParentStateGetCheckedLeafNames这些方法暴露为public,再留一个NodeStateChanged事件给外部调用方。这样以后任何窗体需要权限树,拖一个控件,十分钟搞定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询