feat(表单): 接收班级按「年级」分组下发,对齐家校活动模块页
build / build-push (push) Successful in 1m48s

- ToolParam 新增 groupByParent(),FormField 新增 groupByParent 字段;
  未配 optionsFrom 却声明分组时 toFormField() 直接抛异常
  (内置选项是平铺的,没有父节点,留成死配置最难排查)
- activity.publish 的 receiveList 声明 groupByParent,对齐
  FamilyActivity/src/views/activity/NewActivity.vue 的 OptionGroup 年级分组
- ModuleFormParityTest 新增 2 条:
  activityReceiveListGroupsOptionsByGrade、
  groupByParentRequiresADictionaryThatCanReturnATree(含「没配 optionsFrom 必须抛」的反例)

背景:班级接口返回的是「年级 → 班级」两层树,但助手表单原先把树拍平成叶子,
下拉里只剩一排「一班、二班」,同名班级分不清年级。

验证:mvn -o test 145/145 BUILD SUCCESS
This commit is contained in:
lijing
2026-10-15 17:00:33 +08:00
parent 50f6fa8f75
commit 53c08e10e6
4 changed files with 124 additions and 0 deletions
@@ -185,6 +185,20 @@ public class FormField {
*/
private Object hiddenValue;
/**
* 下拉选项按**父节点**分组渲染(下拉里出现年级分组标题,而不是一整列同名班级)。
*
* <p>搬运自家校活动模块页 {@code NewActivity.vue} 的「接收班级」多选:接口
* {@code /api/base/GetClassTree} 返回**年级 → 班级**两层树,模块页用
* {@code <OptionGroup :label="g.grade">} 渲染;拍平后下拉里只有「一班 / 二班」,
* 高一高二的同名班级分不出来(用户真实反馈)。
*
* <p>勾选后:① 下拉按父节点名分组;② 选中标签与提交摘要用 {@code 父节点名-班级名}
* (模块页的 {@code :label="${g.grade}-${c.label}"},同名班级只凭班级名认不出来)。
* 选项本身仍是**叶子**(班级)的 {@code {value,label}},值不变,提交口径不变。
*/
private boolean groupByParent;
private boolean required;
/** 说明(来自工具定义,写清格式与含义) */
@@ -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;
}
/**
* 选项按**父节点**分组(下拉里出现年级分组标题),并对齐模块页「选中标签带年级」的写法。
*
* <p>搬运自家校活动模块页 {@code NewActivity.vue} 的「接收班级」多选:接口
* {@code /api/base/GetClassTree} 返回的是**年级 → 班级**两层树,模块页把它渲染成
* {@code <OptionGroup :label="g.grade">};拍成一层就分不清年级
* (高一和高二都有一门课叫「一班」,下拉里全是「一班 / 二班」)。
*
* <p>勾选后前端做两件事:
* <ol>
* <li>下拉按父节点名分组成标题(年级);</li>
* <li>选中标签与提交摘要用 {@code 父节点名-班级名} —— 模块页的
* {@code :label="${g.grade}-${c.label}"},因为同名班级只凭班级名认不出来。</li>
* </ol>
*
* <p>★ 只对「{@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);
@@ -161,6 +161,12 @@ public final class ActivityTools {
ToolParam.optional("receiveList", "array", "接收班级:班级 id 列表;留空或含 ALL = 全部班级")
.label("接收班级")
.optionsFrom("activity.classTree").optionsKeys("id", "name")
// ★ 模块页 NewActivity.vue 的「接收班级」是**年级分组**下拉
// (<OptionGroup :label="g.grade">,且 Option 的 :label
// 写成 `${g.grade}-${c.label}` 供选中标签区分同名班级)。
// GetClassTree 返回年级 → 班级两层树,拍平后下拉里
// 只有「一班 / 二班」,高一高二分不出来。
.groupByParent()
.join(",").allOption("ALL", "全部班级"),
ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒家长")
.label("短信提醒").dft("0")
@@ -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 <OptionGroup :label="g.grade">}。
*
* <p>★ 为什么值得钉住:学校的班级树是「高一 → 一班 / 二班」「高二 → 一班 / 二班」,
* 班级名**只到班号**。把树拍平后下拉里全是「一班 / 二班」,用户根本分不清是哪个年级
* (真实反馈:「班级名称都是一班、二班,分不清哪个高一高二」)。
* 这里断言后端确实把「按父节点分组」这个意图下发了 —— 而不是让前端去猜接口返回的是不是树。
*/
@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()}
* 指向树接口」的字段有意义。
*
* <p>★ 为什么:分组靠的是**父节点**。内置选项(没写 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