跳到主要内容

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

配置要点:

  1. 在 Tree Nodes 中维护节点,括号内的值填写到 Name。
  2. 在 Tree 的 onClick 中,把 [[treeNode.name]] 写入 Page Context 的 selectedCategory。
  3. 紧接着调用右侧 ProTable 的 reloadTable;动作顺序不能颠倒。
  4. 在 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 调用保存接口;保存失败时回滚页面结构

复现时建议按以下顺序进行:

  1. 先配置根节点请求、Response List Path 和字段映射,并使用 Test 确认响应可解析。
  2. 再配置按需加载与服务端搜索。
  3. 配置 onClick,验证右侧 ProTable 会随节点变化。
  4. 添加 Node Actions,分别验证抽屉、确认和请求动作。
  5. 最后开启拖拽并配置 onDrop 保存;这样更容易判断问题来自加载、交互还是持久化。

2. 添加 Tree​

  1. 打开目标页面并进入 Page Designer。
  2. 在页面结构中选择 Screen。
  3. 在 Screen 的 Navigation Tree 子元素区域添加 Tree。
  4. 如果使用静态数据,在 Tree 的 Tree Nodes 区域添加根节点和子节点。
  5. 如果使用动态数据,在 Tree 的 Data Source 区域添加 Tree Data Source。
  6. 在 Screen 的 Panels 区域添加 ProTable、Form 或其他内容控件。

Tree 是 Screen 的专用左侧区域。右侧 Panels 和 Grids 会自动成为内容区,不需要再用 Grid 模拟左右栏。

3. 配置 Tree​

属性示例值说明
TitleDynamic OrganizationTree 顶部标题;不需要时可留空
Width320pxPC 端宽度,只支持像素值;窄屏自动改为整行
Show Search开显示搜索框
Show Icon开显示目录和叶节点图标
Selectable开允许选择节点并触发 onClick
Show Connecting Lines开显示父子层级连线
Show Checkbox关需要批量勾选时开启
Strict Mode关开启后,父子节点的勾选状态互不联动
Node Draggable开允许运行时调整节点位置
Drag ScopeSAME_PARENT控制只能同父级排序,还是可以跨层级移动
Node Action TriggerMORE_BUTTON_AND_CONTEXT_MENU使用 More 按钮,并可同时启用桌面右键快捷入口

Drag Scope 有两个值:

  • SAME_PARENT:只能在同一个父节点下调整兄弟节点顺序,不能拖入其他目录。
  • ANY_LEVEL:允许同级排序,也允许拖入其他分支。Tree 会阻止把父节点拖入自己的后代节点。

如果 Tree 只是导航,不承担结构编辑职责,建议关闭 Node Draggable。

4. 静态节点​

每个静态 Tree Node 至少填写:

属性示例值说明
NameACCESSORY稳定的内部值,适合写入 Page Context 或传给接口
TitleAccessories用户看到的节点名称

Name 应保持稳定,不要随显示语言改变。Title 可以使用面向用户的自然语言。

节点包含 Children 时显示为目录;没有 Children 时显示为叶节点。即使配置动态数据,也可以保留少量静态节点作为设计预览和接口失败时的回退内容;动态请求成功后,以接口数据为准。

5. 动态数据源​

在 Tree 的 Data Source 区域添加 Tree Data Source,常用配置如下:

建议先完成根节点请求和字段映射,再配置按需加载和服务端搜索:

  1. 选择 Tree,在属性区找到 Data Source。
  2. 点击 Add data source;已有数据源时点击编辑图标。
  3. 选择 Tree Data Source。
  4. 按下表填写基本属性。
  5. 点击 Test 检查真实响应和解析节点数量。
  6. 展开 Advanced,填写子节点与搜索配置。
  7. 再次测试根节点请求,确认成功后点击 Save。
属性Tree02 示例说明
Root Data URL/spark/api/sample/navigation/tree/nodes获取根节点或整棵树
Request MethodGET支持 GET 或 POST
Request Parameters按需配置可以引用 Page Context、组件值和浏览器参数
Response List Pathdata.nodes从响应中读取节点数组的路径;留空时自动识别常见路径
Title Fieldlabel节点显示标题字段
Value Fieldid节点唯一值;必须稳定且不重复
Children Fieldchildren嵌套子节点数组字段
Leaf Fieldleaf标识叶节点的布尔字段
Disabled Fielddisabled标识节点不可选的布尔字段
Meta Fieldcount标题右侧显示的补充信息,例如子节点数量
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 ParameterparentId接收当前目录 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 Parameterquery发送搜索词的参数名

输入 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 添加操作​

  1. 选择 Tree。
  2. 在 Node Actions 子元素区域添加 Tree Node Action。
  3. 填写 Title,并通过统一 Icon 选择器选择系统图标或上传自定义图片。
  4. 根据需要设置 Tone;删除等危险操作选择 DANGER。
  5. 需要分组时开启 Divider Before。
  6. 选择这个 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
  1. 选择 Tree,在 Node Actions 子元素区域依次添加四个 Tree Node Action。
  2. 选择每个 Action,配置 Title、Icon、Tone、Divider Before,以及按节点显示或禁用的条件。
  3. 仍然选中这个 Action,在 Interaction 中配置 onClick。不要把这些动作配置到 Tree 自身的 onClick 中。
  4. 在子元素列表中拖动 Action,调整右键菜单和 More 菜单的共同顺序。
  5. 回到 Tree,把 Node Action Trigger 设为 MORE_BUTTON_AND_CONTEXT_MENU。
  6. 在设计预览中右键一个节点检查菜单;保存页面后,再到运行态验证抽屉、请求和刷新动作。

Tree02 的四个菜单项可以按下表复现:

Action外观与条件onClick 动作链
EditIcon 选择 edit;Tone 为 DEFAULTSet Component Value:把 [[treeNode.title]]、[[treeNode.data.category]] 填入编辑表单;Toggle Drawer:打开编辑抽屉
Add ChildIcon 选择 link;Tone 为 DEFAULTSet Component Value:把 [[treeNode.value]] 填入父节点字段,清空名称,并带入 [[treeNode.data.category]];Toggle Drawer:打开新增抽屉
View DetailsIcon 选择 view;Tone 为 DEFAULTSet Component Value:把 treeNode 中的节点值、标题、父节点和深度填入只读表单;Toggle Drawer:打开详情抽屉
DeleteIcon 选择 delete;Tone 为 DANGER;开启 Divider Before;Hide This Action 填写 ![[treeNode.isLeaf]],只允许叶节点显示Reminder:确认删除;Send Request:删除当前节点;Component API:调用 Tree 的 reloadTree

Edit 抽屉的 Save 按钮按以下顺序配置:

  1. Send Request:PUT /spark/api/sample/navigation/tree/nodes/[[treeNode.value]]。
  2. 参数 label 读取编辑表单的名称字段,category 读取分类字段。
  3. Component API:调用当前 Tree 的 reloadTree。

Add Child 抽屉的 Create 按钮按以下顺序配置:

  1. Send Request:POST /spark/api/sample/navigation/tree/nodes。
  2. 参数 parentId、label、category 分别读取新增表单中的父节点、名称和分类字段。
  3. 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​

静态商品分类示例按以下顺序配置:

  1. 选择 Tree,打开 Interaction,选择 onClick。
  2. 添加 Set Page Context,Action name 填写 Set selected category。
  3. 在 Action Config 中添加一行:Name 填写 selectedCategory,Value 填写 [[treeNode.name]]。
  4. 再添加 Component API,Action name 填写 Reload product table。
  5. Component 选择右侧 ProTable(示例 ID 为 navigation_products),Function 选择 reloadTable。
  6. 保持 Set Page Context 在前、Component API 在后;否则表格会使用上一次选择的分类重新加载。

在 ProTable 的 Query Context 中添加参数 category,值来源选择 Page Context,路径填写 selectedCategory。点击 Computing 时,接口收到 category=COMPUTING;点击 All Products 时,接口收到 category=ALL,后端再按业务约定转换为不限制分类。

10.2 动态 Tree​

Tree02 的 onClick 按顺序执行:

  1. Set Page Context:把 [[treeNode.value]] 写入 selectedNodeId,并把 [[treeNode.data.category]] 写入 selectedCategory。
  2. 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. 验证与排查​

建议按数据加载、交互联动、节点操作、拖拽保存的顺序验证。前一阶段未通过时,不要继续叠加后续配置。

  1. 在 Data Source 中点击 Test,确认请求成功、Response List Path 正确,并能解析出两个根节点。
  2. 打开页面,确认先看到两个动态根节点。
  3. 展开 Product Catalog,确认此时才请求它的直接子节点。
  4. 展开 Business Operations 和下级目录,确认可以继续按需加载。
  5. 搜索 tier 2,确认结果保留 Operations 和 Customer Support 祖先路径;清空搜索后根节点恢复。
  6. 点击 Computing,确认右侧表格只显示 COMPUTING 商品。
  7. 依次点击 Business Operations、Customer Support 和 Enterprise,确认右侧列表随节点变化,而不是重复显示全部商品。
  8. 打开任一节点的 More 菜单,确认 Edit、Add Child、View Details 和 Delete 按配置显示。
  9. 右键有可见操作的节点,确认打开同一菜单;右键无可见操作的节点时保留浏览器菜单。
  10. 打开菜单但不选择操作,确认右侧表格不会因为 Tree onClick 被刷新。
  11. 依次验证详情、编辑、新增和删除流程;请求完成后确认 Tree 重新加载。
  12. 把 Accessories 拖到 Computing 前面,刷新页面后确认顺序已保存。
  13. 把 Tier 2 拖入 Sales,确认跨分支移动生效。
  14. 模拟保存接口失败,确认 Tree 回滚到拖拽前的位置。

常见问题可以按下表定位:

现象优先检查
Test 请求失败Root Data URL、Request Method、Connection Key、认证状态和 CORS
HTTP 200,但解析节点数为 0Response 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 是否正确返回失败或中断结果