- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user