Tree 控件介绍
Tree 用于展示商品分类、组织架构、目录、区域或资源层级。它可以直接维护静态节点,也可以从接口动态获取根节点、展开时按需加载子节点,并把拖拽结果交给后续动作保存。
阅读本手册后,你可以完成以下配置:
- 使用静态节点快速搭建分类导航
- 通过 Data Source 从接口加载整棵树或按需加载子节点
- 将接口字段映射为节点标题、值、叶节点状态和补充信息
- 使用本地搜索或服务端搜索定位节点
- 读取点击、勾选、展开、加载和拖拽上下文
- 为所有节点统一配置 More 菜单和右键操作
- 限制拖拽范围,并通过 Send Request 保存新顺序
- 使用
reloadTree、expandAll和collapseAll控制 Tree
1. 先选择实现方式
Tree 有两种数据组织方式。先根据数据规模和维护方式选择路径,再进入对应章节配置:
| 场景 | 建议方式 | 重点章节 |
|---|---|---|
| 选项少、结构稳定,由页面开发者维护 | 静态 Tree Nodes | 静态节点、点击节点刷新右侧内容 |
| 数据来自业务系统,或层级会频繁变化 | Tree Data Source | 动态数据源、搜索 |
| 需要新增、编辑、删除或右键菜单 | 动态数据源 + Node Actions | 配置节点操作 |
| 需要调整层级或同级顺序 | 动态数据源 + onDrop | 拖拽与保存 |
下面两个 Spark Sample 页面分别展示静态导航和完整的动态树。截图只用于说明完成后的结构和效果;不需要访问 Sample 应用,也可以按本手册复现。
1.1 Tree:静态商品分类
Tree 页面直接维护少量商品分类。点击节点后,页面先把分类写入 Page Context,再刷新右侧 ProTable。
Screen
├── Navigation Tree
│ └── Tree
│ └── Tree Nodes
│ ├── All Products (ALL)
│ ├── Computing (COMPUTING)
│ ├── Displays (DISPLAY)
│ └── Accessories (ACCESSORY)
└── Panels
└── ProTable
配置要点:
- 在 Tree Nodes 中维护节点,括号内的值填写到 Name。
- 在 Tree 的
onClick中,把[[treeNode.name]]写入 Page Context 的selectedCategory。 - 紧接着调用右侧 ProTable 的
reloadTable;动作顺序不能颠倒。 - 在 ProTable 的 Query Context 中,把
selectedCategory映射为接口参数category。
Interaction 的完整配置步骤见点击节点刷新右侧内容。
1.2 Tree02:动态加载与结构编辑
Tree02 把 Tree 与后端接口、ProTable 和节点操作组合在一起,覆盖以下能力:
| 能力 | 示例配置 |
|---|---|
| 根节点与子节点 | 页面打开时加载根节点;展开目录时按 parentId 加载直接子节点 |
| 搜索 | 将搜索词发送给服务端,并保留命中节点的祖先路径 |
| 右侧内容联动 | 把节点 id 和 category 写入 Page Context,再刷新 ProTable |
| 节点操作 | More 按钮与桌面右键共用 Edit、Add Child、View Details、Delete |
| 拖拽保存 | onDrop 调用保存接口;保存失败时回滚页面结构 |
复现时建议按以下顺序进行:
- 先配置根节点请求、Response List Path 和字段映射,并使用 Test 确认响应可解析。
- 再配置按需加载与服务端搜索。
- 配置
onClick,验证右侧 ProTable 会随节点变化。 - 添加 Node Actions,分别验证抽屉、确认和请求动作。
- 最后开启拖拽并配置
onDrop保存;这样更容易判断问题来自加载、交互还是持久化。
2. 添加 Tree
- 打开目标页面并进入 Page Designer。
- 在页面结构中选择 Screen。
- 在 Screen 的 Navigation Tree 子元素区域添加 Tree。
- 如果使用静态数据,在 Tree 的 Tree Nodes 区域添加根节点和子节点。
- 如果使用动态数据,在 Tree 的 Data Source 区域添加 Tree Data Source。
- 在 Screen 的 Panels 区域添加 ProTable、Form 或其他内容控件。
Tree 是 Screen 的专用左侧区域。右侧 Panels 和 Grids 会自动成为内容区,不需要再用 Grid 模拟左右栏。
3. 配置 Tree
| 属性 | 示例值 | 说明 |
|---|---|---|
| Title | Dynamic Organization | Tree 顶部标题;不需要时可留空 |
| Width | 320px | PC 端宽度,只支持像素值;窄屏自动改为整行 |
| Show Search | 开 | 显示搜索框 |
| Show Icon | 开 | 显示目录和叶节点图标 |
| Selectable | 开 | 允许选择节点并触发 onClick |
| Show Connecting Lines | 开 | 显示父子层级连线 |
| Show Checkbox | 关 | 需要批量勾选时开启 |
| Strict Mode | 关 | 开启后,父子节点的勾选状态互不联动 |
| Node Draggable | 开 | 允许运行时调整节点位置 |
| Drag Scope | SAME_PARENT | 控制只能同父级排序,还是可以跨层级移动 |
| Node Action Trigger | MORE_BUTTON_AND_CONTEXT_MENU | 使用 More 按钮,并可同时启用桌面右键快捷入口 |
Drag Scope 有两个值:
SAME_PARENT:只能在同一个父节点下调整兄弟节点顺序,不能拖入其他目录。ANY_LEVEL:允许同级排序,也允许拖入其他分支。Tree 会阻止把父节点拖入自己的后代节点。
如果 Tree 只是导航,不承担结构编辑职责,建议关闭 Node Draggable。
4. 静态节点
每个静态 Tree Node 至少填写:
| 属性 | 示例值 | 说明 |
|---|---|---|
| Name | ACCESSORY | 稳定的内部值,适合写入 Page Context 或传给接口 |
| Title | Accessories | 用户看到的节点名称 |
Name 应保持稳定,不要随显示语言改变。Title 可以使用面向用户的自然语言。
节点包含 Children 时显示为目录;没有 Children 时显示为叶节点。即使配置动态数据,也可以保留少量静态节点作为设计预览和接口失败时的回退内容;动态请求成功后,以接口数据为准。
5. 动态数据源
在 Tree 的 Data Source 区域添加 Tree Data Source,常用配置如下:
建议先完成根节点请求和字段映射,再配置按需加载和服务端搜索:
- 选择 Tree,在属性区找到 Data Source。
- 点击 Add data source;已有数据源时点击编辑图标。
- 选择 Tree Data Source。
- 按下表填写基本属性。
- 点击 Test 检查真实响应和解析节点数量。
- 展开 Advanced,填写子节点与搜索配置。
- 再次测试根节点请求,确认成功后点击 Save。
| 属性 | Tree02 示例 | 说明 |
|---|---|---|
| Root Data URL | /spark/api/sample/navigation/tree/nodes | 获取根节点或整棵树 |
| Request Method | GET | 支持 GET 或 POST |
| Request Parameters | 按需配置 | 可以引用 Page Context、组件值和浏览器参数 |
| Response List Path | data.nodes | 从响应中读取节点数组的路径;留空时自动识别常见路径 |
| Title Field | label | 节点显示标题字段 |
| Value Field | id | 节点唯一值;必须稳定且不重复 |
| Children Field | children | 嵌套子节点数组字段 |
| Leaf Field | leaf | 标识叶节点的布尔字段 |
| Disabled Field | disabled | 标识节点不可选的布尔字段 |
| Meta Field | count | 标题右侧显示的补充信息,例如子节点数量 |
| Initial Load | 开 | 页面打开时自动请求根节点 |
Root Data URL 可以填写应用内路径、完整 URL,或配合 Connection Key 使用外部连接中的相对路径。
5.1 测试请求与实际响应
Tree Data Source 配置弹窗中的 Test 会实际调用当前 Root Data URL。请求自动带上当前设计器环境和设计预览模式。例如在 DEV 页面中测试 Tree02 时:
X-Spark-Page-Environment: DEV
X-Spark-Page-Render-Mode: DESIGN_PREVIEW
结果区域会显示:
- HTTP 状态码与请求耗时
- 最终 URL 和业务请求参数
- 当前 Response List Path
- 后端返回的实际 JSON(敏感字段会遮罩)
- 按当前数据路径和字段映射解析出的节点数量
如果状态为 200 但解析节点数为 0,优先核对 Response List Path;如果节点数正确但标题为空,再核对 Title Field。如果请求在浏览器中被拦截,检查后端 CORS 是否允许两个 X-Spark-Page-* 请求头。
Test 与设计画布、运行态复用同一套请求构造和响应解析逻辑。因此测试结果可以直接帮助判断问题出在 URL、后端响应、Response List Path,还是字段映射。Data Source 中不需要手工添加环境或渲染模式 Header;完整协议见页面请求上下文与后端调试。
后端先使用共享工具解析 Spark 自动附加的 Header,再把标准请求上下文传入 Service:
@GetMapping("/nodes")
public ApiResult<NavigationTreeDataResult> nodes(
@RequestParam(required = false) String parentId,
@RequestParam(required = false) String query,
@RequestHeader(value = "Accept-Language", required = false) String language,
@RequestHeader(value = SparkPageRequestHeaders.ENVIRONMENT,
required = false) String environment,
@RequestHeader(value = SparkPageRequestHeaders.RENDER_MODE,
required = false) String renderMode) {
Locale locale = SparkApiResultHelper.resolveLocale(language);
SparkPageRequestContext requestContext =
SparkPageRequestHeaders.resolve(environment, renderMode);
NavigationTreeDataResult result = new NavigationTreeDataResult(
treeService.query(parentId, query, requestContext),
requestContext);
return SparkApiResultHelper.success(
locale, result, "SPARK-SAMPLE-NAVIGATION-0002");
}
Service 判断共享枚举,而不是再次解析原始 Header。DEV + DESIGN_PREVIEW 表示设计画布或 Data Source 的 Test 请求,本示例返回一份不会被运行态增删、拖拽影响的稳定预览树;其他组合读取对应环境的运行数据:
private boolean isDevelopmentPreview(
SparkPageRequestContext requestContext) {
return requestContext != null
&& requestContext.getEnvironment()
== SparkPageEnvironment.DEV
&& requestContext.getRenderMode()
== SparkPageRenderMode.DESIGN_PREVIEW;
}
响应中的 requestContext 会显示在 Test 的实际 JSON 中,便于确认后端识别到的环境和渲染模式。两个 Header 只用于数据路由与调试,不能替代登录认证、业务授权或租户隔离。
5.2 一次返回完整层级
如果接口一次返回完整层级,把子节点放入配置的 Children Field:
{
"success": true,
"data": {
"nodes": [
{
"id": "catalog",
"label": "Product Catalog",
"count": "2",
"leaf": false,
"children": [
{
"id": "computing",
"label": "Computing",
"leaf": true,
"children": []
},
{
"id": "display",
"label": "Displays",
"leaf": true,
"children": []
}
]
}
]
}
}
此时 Child Nodes URL 可以留空。
5.3 展开时按需加载
数据量较大或层级较深时,配置:
| 属性 | 示例值 | 说明 |
|---|---|---|
| Child Nodes URL | /spark/api/sample/navigation/tree/nodes | 展开目录时调用的接口;可以与根节点接口相同 |
| Parent Parameter | parentId | 接收当前目录 Value 的参数名 |
根节点请求:
GET /spark/api/sample/navigation/tree/nodes
展开 catalog 时:
GET /spark/api/sample/navigation/tree/nodes?parentId=catalog
使用按需加载时,接口必须通过 Leaf Field 明确告诉 Tree 哪些节点没有子节点。否则 Tree 无法判断是否需要继续展示展开入口。
6. 搜索
默认情况下,搜索只过滤已经加载到页面的节点,不会修改当前选择。
需要在大量节点中搜索尚未加载的分支时,开启 Server-side Search:
| 属性 | 示例值 | 说明 |
|---|---|---|
| Search URL | /spark/api/sample/navigation/tree/nodes | 可选;留空时使用 Root Data URL |
| Search Parameter | query | 发送搜索词的参数名 |
输入 tier 2 后,请求示例为:
GET /spark/api/sample/navigation/tree/nodes?query=tier%202
服务端搜索应返回命中节点及其祖先层级,避免只返回一个孤立叶节点,让用户看不出它所在的位置。清空搜索后,Tree 会重新加载根节点。
7. 当前节点与事件上下文
Tree 会在执行交互前写入相应上下文。
7.1 点击节点:onClick
treeNode.key
treeNode.value
treeNode.name
treeNode.title
treeNode.parentKey
treeNode.parentValue
treeNode.depth
treeNode.isLeaf
treeNode.data
treeNode.extData
动态接口返回的原始节点记录位于:
treeNode.data
例如 Tree02 同时读取节点标识和可选分类:
[[treeNode.value]]
[[treeNode.data.category]]
静态节点原有的扩展字段仍可通过 treeNode.extData 读取。
7.2 勾选节点:onCheck
treeCheckedKeys
treeCheckedNodes
7.3 展开或收起:onExpand
treeExpand.expanded
treeExpand.node
treeExpand.expandedKeys
7.4 数据加载完成:onLoad
treeLoad.parentNode
treeLoad.query
treeLoad.nodeCount
根节点加载时 parentNode 为空;按需加载子节点时,它表示当前展开的父节点。
8. 配置节点操作
节点操作在 Tree 上统一定义,不需要给每一个 Tree Node 重复配置。静态节点、动态根节点和按需加载的子节点都会使用同一组操作,再根据当前节点的数据决定显示或禁用哪些操作。
8.1 添加操作
- 选择 Tree。
- 在 Node Actions 子元素区域添加 Tree Node Action。
- 填写 Title,并通过统一 Icon 选择器选择系统图标或上传自定义图片。
- 根据需要设置 Tone;删除等危险操作选择
DANGER。 - 需要分组时开启 Divider Before。
- 选择这个 Tree Node Action,在 Interaction 中配置
onClick。
操作顺序就是菜单显示顺序,可以在子元素列表中拖动调整。第一项即使开启 Divider Before,也不会在菜单顶部显示多余分隔线。
8.2 配置 More 与右键菜单
右键菜单不是单独的 onContextMenu 事件。Tree 的 More 按钮和右键入口共用 Node Actions 中的同一组操作,因此只需要配置一次菜单项和 Interaction。
先选择 Tree,在属性中设置 Node Action Trigger:
MORE_BUTTON:只使用节点尾部的 More 按钮;右键仍显示浏览器菜单。MORE_BUTTON_AND_CONTEXT_MENU:More 按钮和桌面端右键都打开同一组节点操作。Tree02 使用这个值。
然后在页面结构中完成以下配置:
Tree
└── Node Actions
├── Edit
├── Add Child
├── View Details
└── Delete
- 选择 Tree,在 Node Actions 子元素区域依次添加四个 Tree Node Action。
- 选择每个 Action,配置 Title、Icon、Tone、Divider Before,以及按节点显示或禁用的条件。
- 仍然选中这个 Action,在 Interaction 中配置
onClick。不要把这些动作配置到 Tree 自身的onClick中。 - 在子元素列表中拖动 Action,调整右键菜单和 More 菜单的共同顺序。
- 回到 Tree,把 Node Action Trigger 设为
MORE_BUTTON_AND_CONTEXT_MENU。 - 在设计预览中右键一个节点检查菜单;保存页面后,再到运行态验证抽屉、请求和刷新动作。
Tree02 的四个菜单项可以按下表复现:
| Action | 外观与条件 | onClick 动作链 |
|---|---|---|
| Edit | Icon 选择 edit;Tone 为 DEFAULT | Set Component Value:把 [[treeNode.title]]、[[treeNode.data.category]] 填入编辑表单;Toggle Drawer:打开编辑抽屉 |
| Add Child | Icon 选择 link;Tone 为 DEFAULT | Set Component Value:把 [[treeNode.value]] 填入父节点字段,清空名称,并带入 [[treeNode.data.category]];Toggle Drawer:打开新增抽屉 |
| View Details | Icon 选择 view;Tone 为 DEFAULT | Set Component Value:把 treeNode 中的节点值、标题、父节点和深度填入只读表单;Toggle Drawer:打开详情抽屉 |
| Delete | Icon 选择 delete;Tone 为 DANGER;开启 Divider Before;Hide This Action 填写 ![[treeNode.isLeaf]],只允许叶节点显示 | Reminder:确认删除;Send Request:删除当前节点;Component API:调用 Tree 的 reloadTree |
Edit 抽屉的 Save 按钮按以下顺序配置:
- Send Request:
PUT /spark/api/sample/navigation/tree/nodes/[[treeNode.value]]。 - 参数
label读取编辑表单的名称字段,category读取分类字段。 - Component API:调用当前 Tree 的
reloadTree。
Add Child 抽屉的 Create 按钮按以下顺序配置:
- Send Request:
POST /spark/api/sample/navigation/tree/nodes。 - 参数
parentId、label、category分别读取新增表单中的父节点、名称和分类字段。 - Component API:调用当前 Tree 的
reloadTree。
Delete 的 Send Request 使用:
DELETE /spark/api/sample/navigation/tree/nodes/[[treeNode.value]]
只有请求成功后才继续执行 reloadTree。后端仍必须独立校验节点权限和删除条件,不能把 Hide This Action 当作安全校验。
8.3 菜单触发行为
Node Action Trigger 支持:
MORE_BUTTON:只显示节点尾部的 More 按钮。MORE_BUTTON_AND_CONTEXT_MENU:保留 More 按钮,同时允许桌面端右键打开同一菜单。
MORE_BUTTON_AND_CONTEXT_MENU 只有在当前节点至少存在一个可见操作时,才会阻止浏览器默认右键菜单。触屏设备始终使用 More 按钮,不使用长按代替右键。More 按钮和菜单会阻止事件冒泡,因此不会同时触发节点点击、勾选、展开或拖拽。
键盘用户可以聚焦 More 按钮后按 Enter 或 Space;也可以在节点上按 Menu 键或 Shift+F10。菜单内使用上下方向键移动,Enter 或 Space 执行,Escape 关闭并回到原来的触发位置。
8.4 按节点显示或禁用操作
在 Advanced 中可以配置:
| 属性 | 作用 | 示例 |
|---|---|---|
| Hide This Action | 表达式为 true 时不显示操作 | [[treeNode.isLeaf]] |
| Disable This Action | 表达式为 true 时显示但不可执行 | [[treeNode.data.locked]] |
表达式可以读取 treeNode,并使用 !、&&、|| 和常见比较符,例如:
[[treeNode.data.status]] !== 'ACTIVE'
[[treeNode.depth]] >= 3
![[treeNode.data.canDelete]]
条件格式错误时,Hide 条件对应的操作会隐藏,Disable 条件对应的操作会保持禁用;Page Designer 会显示配置诊断,避免错误操作在运行时意外可用。
8.5 操作上下文
打开菜单时,Tree 会把目标节点写入 treeNode 和 selectedTreeNode,但不会触发 Tree 的 onClick。真正选择操作后,还会写入:
treeAction.id
treeAction.elementId
treeAction.title
treeAction.tone
随后只执行所选 Tree Node Action 的 onClick。在 Interaction 中可以组合 Drawer、Modal、Reminder、Send Request、Set Page Context 和 Component API。例如 Tree02 使用:
- Edit:打开编辑抽屉,并从
treeNode.data读取当前节点。 - Add Child:打开新增子节点抽屉,使用
treeNode.value作为父节点。 - View Details:打开只读详情抽屉。
- Delete:先显示 Reminder,再发送删除请求,最后调用 Tree 的
reloadTree。
Page Designer 预览允许打开菜单检查布局和条件,但不会执行这些 Interaction;保存页面后再到运行态验证完整动作。
9. 拖拽与保存
开启 Node Draggable 后,拖拽会先在页面中完成移动,再触发 onDrop。后续动作可以读取:
treeDrop.dragNode
treeDrop.dropNode
treeDrop.position
treeDrop.oldParentKey
treeDrop.newParentKey
treeDrop.oldIndex
treeDrop.newIndex
treeDrop.treeData
position 的值为:
BEFORE:放到目标节点之前。INSIDE:放入目标节点内部。AFTER:放到目标节点之后。
拖拽只会改变当前页面中的结构。要让刷新页面后仍保留新顺序,需要在 onDrop 中配置 Send Request。
Tree02 的保存接口为:
POST /spark/api/sample/navigation/tree/reorder
请求参数配置:
| 参数 | 值 |
|---|---|
dragId | [[treeDrop.dragNode.key]] |
dropId | [[treeDrop.dropNode.key]] |
position | [[treeDrop.position]] |
对应请求:
{
"dragId": "accessory",
"dropId": "computing",
"position": "BEFORE"
}
如果保存动作成功,新顺序保留;如果 Send Request 失败或中断,Tree 会回滚到拖拽前的结构。后端仍应校验节点是否存在、目标父节点是否合法、是否形成循环引用,以及当前用户是否有调整权限。
10. 点击节点刷新右侧内容
Tree 本身只发布当前节点。要让右侧 ProTable、DataList 或 Form 随选择变化,需要先写入 Page Context,再调用目标组件的刷新函数。
10.1 静态 Tree
静态商品分类示例按以下顺序配置:
- 选择 Tree,打开 Interaction,选择
onClick。 - 添加 Set Page Context,Action name 填写
Set selected category。 - 在 Action Config 中添加一行:Name 填写
selectedCategory,Value 填写[[treeNode.name]]。 - 再添加 Component API,Action name 填写
Reload product table。 - Component 选择右侧 ProTable(示例 ID 为
navigation_products),Function 选择reloadTable。 - 保持 Set Page Context 在前、Component API 在后;否则表格会使用上一次选择的分类重新加载。
在 ProTable 的 Query Context 中添加参数 category,值来源选择 Page Context,路径填写 selectedCategory。点击 Computing 时,接口收到 category=COMPUTING;点击 All Products 时,接口收到 category=ALL,后端再按业务约定转换为不限制分类。
10.2 动态 Tree
Tree02 的 onClick 按顺序执行:
- Set Page Context:把
[[treeNode.value]]写入selectedNodeId,并把[[treeNode.data.category]]写入selectedCategory。 - Component API:调用右侧 ProTable 的
reloadTable。
ProTable 的 Query Context 再把这两个上下文值映射为接口参数 nodeId 和 category。商品分类节点使用 category 筛选;Business Operations 下的节点由示例接口根据 nodeId 映射为对应的业务数据视图。这样目录节点、业务节点和用户新增的分类节点都能使用同一套点击交互。动作顺序必须先写入上下文,再刷新表格。
动态 Tree 应优先读取数据源明确返回的业务字段,不要依赖仅在静态节点上配置的扩展值。
11. 组件函数
其他交互可以通过 Component API 调用 Tree:
| Function | 作用 |
|---|---|
reloadTree | 重新请求根节点,并保留仍然有效的当前选择 |
expandAll | 展开当前已经加载的所有目录 |
collapseAll | 收起所有目录 |
expandAll 只展开已经加载到页面的数据;它不会为了展开整棵超大树而自动请求所有未加载分支。
12. Java 后端示例
下面的 Java 代码与 Tree02 使用的字段、接口地址和交互参数对应,适用于 Java 17 和 Spring Boot 4。示例使用内存数据突出 Tree 的接口契约;在正式项目中,可以把 NodeRecord 和内存集合替换为 Repository 或其他数据服务,Controller 对外仍返回 DTO。
12.1 定义节点与写入请求
动态节点应返回稳定的 id,并明确提供 leaf。按需加载时,非叶节点的 children 可以先返回空数组。
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class NavigationTreeNodeDTO {
private String id;
private String label;
private String parentId;
private String category;
private String count;
private boolean leaf;
private boolean disabled;
private List<NavigationTreeNodeDTO> children = new ArrayList<>();
}
@Getter
@Setter
public class NavigationTreeNodeWriteRequest {
private String parentId;
private String label;
private String category;
}
@Getter
@Setter
public class NavigationTreeReorderRequest {
private String dragId;
private String dropId;
private String position;
}
Tree02 把节点数组和页面请求上下文一起放入响应数据:
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class NavigationTreeDataResult {
private List<NavigationTreeNodeDTO> nodes = new ArrayList<>();
private SparkPageRequestContext requestContext;
}
12.2 提供 Tree 接口
同一个查询接口同时处理根节点、子节点和服务端搜索。环境与渲染模式从 Spark 的标准请求头解析,不需要在控件或业务参数中重复传递。
@RestController
@RequestMapping("/spark/api/sample/navigation/tree")
public class NavigationTreeController {
private final NavigationTreeService treeService;
public NavigationTreeController(NavigationTreeService treeService) {
this.treeService = treeService;
}
@GetMapping("/nodes")
public ApiResult<NavigationTreeDataResult> nodes(
@RequestParam(required = false) String parentId,
@RequestParam(required = false) String query,
@RequestHeader(value = "Accept-Language", required = false) String language,
@RequestHeader(value = SparkPageRequestHeaders.ENVIRONMENT,
required = false) String environment,
@RequestHeader(value = SparkPageRequestHeaders.RENDER_MODE,
required = false) String renderMode) {
Locale locale = SparkApiResultHelper.resolveLocale(language);
SparkPageRequestContext requestContext =
SparkPageRequestHeaders.resolve(environment, renderMode);
NavigationTreeDataResult result = new NavigationTreeDataResult(
treeService.query(parentId, query, requestContext),
requestContext);
return SparkApiResultHelper.success(
locale, result, "SPARK-SAMPLE-NAVIGATION-0002");
}
@PostMapping("/nodes")
public ApiResult<NavigationTreeNodeDTO> create(
@RequestBody NavigationTreeNodeWriteRequest request,
@RequestHeader(value = "Accept-Language", required = false) String language) {
return SparkApiResultHelper.success(
SparkApiResultHelper.resolveLocale(language),
treeService.create(request), "SPARK-SAMPLE-NAVIGATION-0009");
}
@PutMapping("/nodes/{nodeId}")
public ApiResult<NavigationTreeNodeDTO> update(
@PathVariable String nodeId,
@RequestBody NavigationTreeNodeWriteRequest request,
@RequestHeader(value = "Accept-Language", required = false) String language) {
return SparkApiResultHelper.success(
SparkApiResultHelper.resolveLocale(language),
treeService.update(nodeId, request), "SPARK-SAMPLE-NAVIGATION-0010");
}
@DeleteMapping("/nodes/{nodeId}")
public ApiResult<NavigationTreeNodeDTO> delete(
@PathVariable String nodeId,
@RequestHeader(value = "Accept-Language", required = false) String language) {
return SparkApiResultHelper.success(
SparkApiResultHelper.resolveLocale(language),
treeService.delete(nodeId), "SPARK-SAMPLE-NAVIGATION-0011");
}
@PostMapping("/reorder")
public ApiResult<NavigationTreeNodeDTO> reorder(
@RequestBody NavigationTreeReorderRequest request,
@RequestHeader(value = "Accept-Language", required = false) String language) {
return SparkApiResultHelper.success(
SparkApiResultHelper.resolveLocale(language),
treeService.reorder(request), "SPARK-SAMPLE-NAVIGATION-0003");
}
}
这些接口分别对应 Tree02 的 Data Source、Node Actions 和 onDrop:
| 场景 | 方法与地址 |
|---|---|
| 根节点、子节点与搜索 | GET /spark/api/sample/navigation/tree/nodes |
| 新增子节点 | POST /spark/api/sample/navigation/tree/nodes |
| 编辑节点 | PUT /spark/api/sample/navigation/tree/nodes/{nodeId} |
| 删除节点 | DELETE /spark/api/sample/navigation/tree/nodes/{nodeId} |
| 保存拖拽结果 | POST /spark/api/sample/navigation/tree/reorder |
12.3 实现按需加载与服务端搜索
查询未提供 parentId 时返回根节点;提供 parentId 时只返回直接子节点。搜索时从根节点递归过滤,并保留命中节点的祖先路径。
@Service
public class NavigationTreeService {
private final Map<String, NodeRecord> nodes = new LinkedHashMap<>();
private final Map<String, NodeRecord> designPreviewNodes =
new LinkedHashMap<>();
public NavigationTreeService() {
populateBaseline(designPreviewNodes);
reset();
}
public synchronized List<NavigationTreeNodeDTO> query(
String parentId,
String query,
SparkPageRequestContext requestContext) {
Map<String, NodeRecord> source = isDevelopmentPreview(requestContext)
? designPreviewNodes
: nodes;
String keyword = StringUtils.trimToNull(query);
if (keyword != null) {
return children(source, null).stream()
.map(root -> filterTree(source, root, keyword))
.filter(Objects::nonNull)
.toList();
}
return children(source, StringUtils.trimToNull(parentId)).stream()
.map(node -> toDto(source, node))
.toList();
}
private boolean isDevelopmentPreview(
SparkPageRequestContext requestContext) {
return requestContext != null
&& requestContext.getEnvironment()
== SparkPageEnvironment.DEV
&& requestContext.getRenderMode()
== SparkPageRenderMode.DESIGN_PREVIEW;
}
private NavigationTreeNodeDTO filterTree(
Map<String, NodeRecord> source,
NodeRecord node,
String keyword) {
List<NavigationTreeNodeDTO> matchingChildren =
children(source, node.id).stream()
.map(child -> filterTree(source, child, keyword))
.filter(Objects::nonNull)
.toList();
boolean matches = StringUtils.containsIgnoreCase(node.label, keyword)
|| StringUtils.containsIgnoreCase(node.category, keyword);
if (!matches && matchingChildren.isEmpty()) {
return null;
}
NavigationTreeNodeDTO dto = toDto(source, node);
dto.setChildren(matchingChildren);
dto.setCount(String.valueOf(matchingChildren.isEmpty()
? children(source, node.id).size()
: matchingChildren.size()));
return dto;
}
private List<NodeRecord> children(
Map<String, NodeRecord> source,
String parentId) {
return source.values().stream()
.filter(node -> Objects.equals(node.parentId, parentId))
.sorted(Comparator.comparingInt(node -> node.order))
.collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
}
private NavigationTreeNodeDTO toDto(
Map<String, NodeRecord> source,
NodeRecord node) {
int childCount = children(source, node.id).size();
return new NavigationTreeNodeDTO(
node.id, node.label, node.parentId, node.category,
childCount == 0 ? "" : String.valueOf(childCount),
childCount == 0, false, new ArrayList<>());
}
// populateBaseline creates a stable development-preview tree.
// A production implementation can route repositories by environment.
}
Tree02 的 Test 使用 DEV + DESIGN_PREVIEW,因此读取稳定的 designPreviewNodes。即使开发运行态已经执行过新增、删除或拖拽,重新打开数据源测试仍能得到可重复的根节点结构。DEV + RUNTIME、TEST + RUNTIME 和 PROD + RUNTIME 则走对应环境的业务数据。正式项目可以把示例中的两个 Map 替换为 Preview Provider 和按环境路由的 Repository。
12.4 实现节点新增、编辑与删除
Node Actions 调用的新增、编辑和删除接口,可以使用下面的服务逻辑。示例只允许删除叶节点;正式项目还应校验当前用户是否有权修改目标节点。
public synchronized NavigationTreeNodeDTO create(
NavigationTreeNodeWriteRequest request) {
String parentId = StringUtils.trimToNull(
request == null ? null : request.getParentId());
String label = StringUtils.trimToNull(
request == null ? null : request.getLabel());
if (label == null || parentId == null || !nodes.containsKey(parentId)) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0007"));
}
String id = "custom-"
+ UUID.randomUUID().toString().substring(0, 8);
add(id, label, parentId,
StringUtils.trimToNull(request.getCategory()),
children(parentId).size() * 10 + 10);
return toDto(nodes.get(id));
}
public synchronized NavigationTreeNodeDTO update(
String nodeId, NavigationTreeNodeWriteRequest request) {
NodeRecord node = nodes.get(StringUtils.trimToNull(nodeId));
String label = StringUtils.trimToNull(
request == null ? null : request.getLabel());
if (node == null || label == null) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0007"));
}
node.label = label;
node.category = StringUtils.trimToNull(request.getCategory());
return toDto(node);
}
public synchronized NavigationTreeNodeDTO delete(String nodeId) {
String id = StringUtils.trimToNull(nodeId);
NodeRecord node = nodes.get(id);
if (node == null || !children(id).isEmpty()) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0008"));
}
nodes.remove(id);
return toDto(node);
}
用户可见的校验失败通过 LatticeRuntimeException(Message.code(...)) 返回,具体文案维护在服务端国际化资源中,不要在 Service 中直接拼接错误字符串。
12.5 保存拖拽结果
服务端不能直接相信前端位置。下面的核心逻辑会检查节点、位置和循环引用,再更新父节点与同级顺序。业务系统还应在这里增加用户权限和并发版本校验。
public synchronized NavigationTreeNodeDTO reorder(
NavigationTreeReorderRequest request) {
String dragId = StringUtils.trimToNull(
request == null ? null : request.getDragId());
String dropId = StringUtils.trimToNull(
request == null ? null : request.getDropId());
String position = StringUtils.upperCase(
StringUtils.defaultIfBlank(
request == null ? null : request.getPosition(), "AFTER"),
Locale.ROOT);
NodeRecord dragged = nodes.get(dragId);
NodeRecord dropped = nodes.get(dropId);
if (dragged == null || dropped == null
|| StringUtils.equals(dragId, dropId)) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0005"));
}
if (!List.of("BEFORE", "INSIDE", "AFTER").contains(position)
|| isDescendant(dropId, dragId)) {
throw new LatticeRuntimeException(
Message.code("SPARK-SAMPLE-NAVIGATION-0006"));
}
String newParentId = StringUtils.equals(position, "INSIDE")
? dropId : dropped.parentId;
List<NodeRecord> oldSiblings = children(dragged.parentId);
oldSiblings.removeIf(node -> StringUtils.equals(node.id, dragId));
applyOrder(oldSiblings);
List<NodeRecord> newSiblings = children(newParentId);
newSiblings.removeIf(node -> StringUtils.equals(node.id, dragId));
int dropIndex = indexOf(newSiblings, dropId);
int insertIndex = StringUtils.equals(position, "INSIDE")
? newSiblings.size()
: Math.max(0, dropIndex
+ (StringUtils.equals(position, "AFTER") ? 1 : 0));
dragged.parentId = newParentId;
newSiblings.add(Math.min(insertIndex, newSiblings.size()), dragged);
applyOrder(newSiblings);
return toDto(dragged);
}
示例中的 applyOrder 按当前列表顺序重新写入排序值;数据库实现应在同一事务中更新父节点和排序字段。如果保存失败,返回非成功结果,让 Tree 的 onDrop 自动回滚页面中的移动。
12.6 根据点击节点筛选右侧列表
右侧 ProTable 的请求对象在通用分页查询基础上增加 nodeId。Tree 的分类字段仍通过 category 传递;没有分类字段的业务节点则由服务端按照 nodeId 转换成对应查询条件。
@Getter
@Setter
public class NavigationProductQueryRequest
extends ProTableProductQueryRequest {
private String nodeId;
}
@Service
public class NavigationProductService {
private final ProTableProductService productService;
public NavigationProductService(ProTableProductService productService) {
this.productService = productService;
}
public ProTablePageResult<ProTableProductDTO> query(
NavigationProductQueryRequest request,
SparkPageRequestContext requestContext) {
NavigationProductQueryRequest query = request == null
? new NavigationProductQueryRequest() : request;
if (StringUtils.equalsIgnoreCase(query.getCategory(), "ALL")) {
query.setCategory(null);
}
applyBusinessNodeFilter(query);
return productService.query(query, requestContext);
}
private void applyBusinessNodeFilter(
NavigationProductQueryRequest query) {
String nodeId = StringUtils.lowerCase(
StringUtils.trimToEmpty(query.getNodeId()), Locale.ROOT);
switch (nodeId) {
case "operations" -> query.setStatus("DRAFT");
case "fulfillment" -> query.setCategory("STORAGE");
case "picking" -> query.setKeyword("Drive");
case "packing" -> query.setCategory("ACCESSORY");
case "support" -> query.setStatus("DISCONTINUED");
case "tier1" -> query.setCategory("NETWORK");
case "tier2" -> query.setCategory("AUDIO");
case "sales" -> query.setMinPrice(BigDecimal.valueOf(500));
case "enterprise" -> query.setMinPrice(BigDecimal.valueOf(1000));
case "online" -> query.setMaxPrice(BigDecimal.valueOf(100));
default -> {
// 分类节点继续使用 Tree 返回的 category。
}
}
}
}
对应的 Controller 接收 Spark 页面请求上下文,并调用上面的查询服务:
@RestController
@RequestMapping("/spark/api/sample/navigation/products")
public class NavigationProductController {
private final NavigationProductService productService;
public NavigationProductController(
NavigationProductService productService) {
this.productService = productService;
}
@PostMapping
public ApiResult<ProTablePageResult<ProTableProductDTO>> query(
@RequestBody(required = false) NavigationProductQueryRequest request,
@RequestHeader(value = "Accept-Language", required = false) String language,
@RequestHeader(value = SparkPageRequestHeaders.ENVIRONMENT,
required = false) String environment,
@RequestHeader(value = SparkPageRequestHeaders.RENDER_MODE,
required = false) String renderMode) {
Locale locale = SparkApiResultHelper.resolveLocale(language);
return SparkApiResultHelper.success(
locale,
productService.query(request,
SparkPageRequestHeaders.resolve(environment, renderMode)),
"SPARK-SAMPLE-NAVIGATION-0001");
}
}
13. 验证与排查
建议按数据加载、交互联动、节点操作、拖拽保存的顺序验证。前一阶段未通过时,不要继续叠加后续配置。
- 在 Data Source 中点击 Test,确认请求成功、Response List Path 正确,并能解析出两个根节点。
- 打开页面,确认先看到两个动态根节点。
- 展开 Product Catalog,确认此时才请求它的直接子节点。
- 展开 Business Operations 和下级目录,确认可以继续按需加载。
- 搜索
tier 2,确认结果保留 Operations 和 Customer Support 祖先路径;清空搜索后根节点恢复。 - 点击 Computing,确认右侧表格只显示 COMPUTING 商品。
- 依次点击 Business Operations、Customer Support 和 Enterprise,确认右侧列表随节点变化,而不是重复显示全部商品。
- 打开任一节点的 More 菜单,确认 Edit、Add Child、View Details 和 Delete 按配置显示。
- 右键有可见操作的节点,确认打开同一菜单;右键无可见操作的节点时保留浏览器菜单。
- 打开菜单但不选择操作,确认右侧表格不会因为 Tree
onClick被刷新。 - 依次验证详情、编辑、新增和删除流程;请求完成后确认 Tree 重新加载。
- 把 Accessories 拖到 Computing 前面,刷新页面后确认顺序已保存。
- 把 Tier 2 拖入 Sales,确认跨分支移动生效。
- 模拟保存接口失败,确认 Tree 回滚到拖拽前的位置。
常见问题可以按下表定位:
| 现象 | 优先检查 |
|---|---|
| Test 请求失败 | Root Data URL、Request Method、Connection Key、认证状态和 CORS |
| HTTP 200,但解析节点数为 0 | Response List Path 是否指向数组 |
| 节点有数量但没有标题 | Title Field、Value Field 与实际 JSON 字段名 |
| 目录没有展开入口 | Leaf Field 是否错误地返回 true |
| 点击目录后没有子节点 | Child Nodes URL、Parent Parameter,以及父节点 Value 是否正确 |
| 搜索只返回孤立叶节点 | 服务端是否同时返回命中节点的祖先路径 |
| 点击不同节点,右侧列表不变化 | Page Context 是否先更新,ProTable Query Context 是否读取同一路径 |
| 菜单项没有出现 | Node Action Trigger、Hide This Action 和当前节点数据 |
| 拖拽后刷新恢复旧顺序 | onDrop 是否发送 treeDrop 参数,后端是否持久化父节点和顺序 |
| 保存失败后页面没有回滚 | Send Request 是否正确返回失败或中断结果 |