diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java index 9bc5218..73bb98d 100644 --- a/src/main/java/com/yunhai/agent/tool/FormField.java +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -185,6 +185,20 @@ public class FormField { */ private Object hiddenValue; + /** + * 下拉选项按**父节点**分组渲染(下拉里出现年级分组标题,而不是一整列同名班级)。 + * + *
搬运自家校活动模块页 {@code NewActivity.vue} 的「接收班级」多选:接口
+ * {@code /api/base/GetClassTree} 返回**年级 → 班级**两层树,模块页用
+ * {@code 勾选后:① 下拉按父节点名分组;② 选中标签与提交摘要用 {@code 父节点名-班级名}
+ * (模块页的 {@code :label="${g.grade}-${c.label}"},同名班级只凭班级名认不出来)。
+ * 选项本身仍是**叶子**(班级)的 {@code {value,label}},值不变,提交口径不变。
+ */
+ private boolean groupByParent;
+
private boolean required;
/** 说明(来自工具定义,写清格式与含义) */
diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java
index 681e6ca..a13ca33 100644
--- a/src/main/java/com/yunhai/agent/tool/ToolParam.java
+++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java
@@ -267,6 +267,11 @@ public class ToolParam {
*/
private Object hiddenValue;
+ /**
+ * 下拉选项按**父节点**分组渲染(见 {@link #groupByParent()})。
+ */
+ private boolean groupByParent;
+
public ToolParam() {
}
@@ -617,6 +622,29 @@ public class ToolParam {
return this;
}
+ /**
+ * 选项按**父节点**分组(下拉里出现年级分组标题),并对齐模块页「选中标签带年级」的写法。
+ *
+ * 搬运自家校活动模块页 {@code NewActivity.vue} 的「接收班级」多选:接口
+ * {@code /api/base/GetClassTree} 返回的是**年级 → 班级**两层树,模块页把它渲染成
+ * {@code 勾选后前端做两件事:
+ * ★ 只对「{@link #optionsFrom} 指向的接口返回**树**」的字段有意义:接口返回平铺字典时
+ * 没有父节点可分组,声明了也不会生效({@link #toFormField()} 会直接报错,避免留成死配置)。
+ */
+ public ToolParam groupByParent() {
+ this.groupByParent = true;
+ return this;
+ }
+
/**
* 覆盖附件上传地址。★ 传**模块内相对路径**(如 {@code /api/file/Upload}),
* 模块前缀由编排层统一拼;不传则用模块默认的 {@code /api/base/FileUpload}。
@@ -770,6 +798,14 @@ public class ToolParam {
"参数 " + name + " 声明了 hiddenValue 却没有 visibleWhen —— 字段恒显示,该固定值永远用不上");
}
f.setHiddenValue(hiddenValue);
+ // 选项按父节点分组(家校活动「接收班级」:年级 → 班级 两层树)
+ // ★ 没声明 optionsFrom 的字段选项是**内置平铺列表**,没有父节点可分组
+ // ⇒ 直接报错,别留成「写了却毫无效果」的死配置。
+ if (groupByParent && StringUtils.isBlank(optionsFrom)) {
+ throw new IllegalStateException(
+ "参数 " + name + " 声明了 groupByParent 却没有 optionsFrom —— 内置选项是平铺的,没有父节点可分组");
+ }
+ f.setGroupByParent(groupByParent);
// 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼)
f.setRichTextUploadPath(richTextUploadOverride);
f.setRichTextPreviewPath(richTextPreviewOverride);
diff --git a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java
index ef11c2b..e96a31e 100644
--- a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java
+++ b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java
@@ -161,6 +161,12 @@ public final class ActivityTools {
ToolParam.optional("receiveList", "array", "接收班级:班级 id 列表;留空或含 ALL = 全部班级")
.label("接收班级")
.optionsFrom("activity.classTree").optionsKeys("id", "name")
+ // ★ 模块页 NewActivity.vue 的「接收班级」是**年级分组**下拉
+ // ( ★ 为什么值得钉住:学校的班级树是「高一 → 一班 / 二班」「高二 → 一班 / 二班」,
+ * 班级名**只到班号**。把树拍平后下拉里全是「一班 / 二班」,用户根本分不清是哪个年级
+ * (真实反馈:「班级名称都是一班、二班,分不清哪个高一高二」)。
+ * 这里断言后端确实把「按父节点分组」这个意图下发了 —— 而不是让前端去猜接口返回的是不是树。
+ */
+ @Test
+ void activityReceiveListGroupsOptionsByGrade() {
+ ToolParam receive = param(registry.get("activity.publish"), "receiveList");
+ assertNotNull(receive, "家校活动发布要有「接收班级」");
+ assertTrue(receive.isGroupByParent(), "「接收班级」要按年级分组(模块页 OptionGroup 同款)");
+ assertEquals("activity.classTree", receive.getOptionsFrom(),
+ "分组依据是班级树接口 —— 平铺字典没有父节点可分组");
+ assertEquals("id", receive.getOptionValueKey());
+ assertEquals("name", receive.getOptionLabelKey(),
+ "分组标题取父节点的 name(模块页 :label=\"g.grade\" 用的也是同一棵树)");
+ assertEquals("ALL", receive.getAllToken(), "「全部班级」哨兵不归任何年级,仍单独下发");
+
+ // 下发到前端的字段同样要带这个标记(前端据此走 parseOptionGroups 而不是 parseOptionItems)
+ assertTrue(receive.toFormField().isGroupByParent());
+
+ // 分组的前提:这个字典本身是「一次拿到整棵树」的接口
+ ToolDef tree = registry.get("activity.classTree");
+ assertNotNull(tree, "要有班级树字典工具");
+ assertEquals("/api/base/GetClassTree", tree.getPath());
+ }
+
+ /**
+ * 通用不变量:{@code groupByParent}(按父节点分组)只对「{@link ToolParam#getOptionsFrom()}
+ * 指向树接口」的字段有意义。
+ *
+ * ★ 为什么:分组靠的是**父节点**。内置选项(没写 optionsFrom)是后端写死的平铺列表,
+ * 压根没有父节点 —— 声明了既不报错也毫无效果,是最难发现的死配置。
+ * 所以 {@link ToolParam#toFormField()} 直接抛异常,这里把两种情形一起钉住:
+ * ① 现存工具里凡标了分组的,必须都有 optionsFrom + labelKey;
+ * ② 反例(标了分组却没 optionsFrom)必须**抛异常**,而不是静默失效。
+ */
+ @Test
+ void groupByParentRequiresADictionaryThatCanReturnATree() {
+ int checked = 0;
+ for (ToolDef def : registry.all()) {
+ for (ToolParam p : def.getParams()) {
+ if (!p.isGroupByParent()) {
+ continue;
+ }
+ checked++;
+ assertTrue(StringUtils.isNotBlank(p.getOptionsFrom()), def.getName() + " 的「" + p.getName()
+ + "」标了分组却没有 optionsFrom —— 内置选项是平铺的,没有父节点可分组");
+ assertNotNull(registry.get(p.getOptionsFrom()), def.getName() + " 的「" + p.getName()
+ + "」的 optionsFrom=" + p.getOptionsFrom() + " 不在本系统的字典工具里");
+ assertTrue(StringUtils.isNotBlank(p.getOptionLabelKey()), def.getName() + " 的「" + p.getName()
+ + "」标了分组却没写 labelKey —— 分组标题要从父节点取名字");
+ }
+ }
+ assertTrue(checked >= 1, "至少应覆盖家校活动的「接收班级」,实际 " + checked);
+
+ // 反例:没有 optionsFrom 就不该声明分组(否则是「写了却没效果」的死配置)
+ ToolParam bad = ToolParam.optional("x", "array", "x").label("x").groupByParent();
+ IllegalStateException e = assertThrows(IllegalStateException.class, bad::toFormField);
+ assertTrue(e.getMessage().contains("groupByParent"), e.getMessage());
+ }
+
/* ==================== ③ 活动:分类存名称 ==================== */
@Test
+ *
+ *
+ *