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 };拍成一层就分不清年级 + * (高一和高二都有一门课叫「一班」,下拉里全是「一班 / 二班」)。 + * + *

勾选后前端做两件事: + *

    + *
  1. 下拉按父节点名分组成标题(年级);
  2. + *
  3. 选中标签与提交摘要用 {@code 父节点名-班级名} —— 模块页的 + * {@code :label="${g.grade}-${c.label}"},因为同名班级只凭班级名认不出来。
  4. + *
+ * + *

★ 只对「{@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 的「接收班级」是**年级分组**下拉 + // (,且 Option 的 :label + // 写成 `${g.grade}-${c.label}` 供选中标签区分同名班级)。 + // GetClassTree 返回年级 → 班级两层树,拍平后下拉里 + // 只有「一班 / 二班」,高一高二分不出来。 + .groupByParent() .join(",").allOption("ALL", "全部班级"), ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒家长") .label("短信提醒").dft("0") diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java index 1023e1f..044ba38 100644 --- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java +++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java @@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -275,6 +276,73 @@ class ModuleFormParityTest { assertTrue(checked >= 2, "至少应覆盖家校活动的两处(名额上限 / 家长随行),实际 " + checked); } + /* ==================== ③ 活动:接收班级按年级分组 ==================== */ + + /** + * 家校活动「接收班级」必须**按年级分组**下发 —— 对齐模块页 {@code NewActivity.vue} 的 + * {@code }。 + * + *

★ 为什么值得钉住:学校的班级树是「高一 → 一班 / 二班」「高二 → 一班 / 二班」, + * 班级名**只到班号**。把树拍平后下拉里全是「一班 / 二班」,用户根本分不清是哪个年级 + * (真实反馈:「班级名称都是一班、二班,分不清哪个高一高二」)。 + * 这里断言后端确实把「按父节点分组」这个意图下发了 —— 而不是让前端去猜接口返回的是不是树。 + */ + @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