diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java index c3d54e6..9bc5218 100644 --- a/src/main/java/com/yunhai/agent/tool/FormField.java +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -149,6 +149,42 @@ public class FormField { /** 文本最大长度(前端显示字数统计) */ private Integer maxLength; + /** + * 条件显示:本字段**仅当**所列字段的当前取值命中给定值时渲染、校验、提交。 + * + *
形如 {@code {receiptMode: ["confirm","fill"]}} = 「宿管到场配合」只在需要回执时出现; + * 多个键之间是「且」的关系;为空 = 恒显示(老字段全都是这个形态,行为不变)。 + * + *
★ 为什么要这条能力:宿舍通知模块页「新建通知」里,**回执方式**这个单选决定了下面
+ * 还显示哪些参数({@code NewNotice.vue} 的 {@code showReceiptOpts / isFill} 两个 computed):
+ * 「仅通知」时既没有「回执单据」,也没有「宿管到场配合」。助手的表单若把字段一次性全摆出来,
+ * 用户会看到两个当前场景根本不存在的参数,还得自己判断该不该填。
+ */
+ private Map ★ 为什么不能直接用 {@link #required}:{@code required} 会被写进**给模型的 JSON Schema**
+ * 的 {@code required} 数组里,于是模型在「仅通知」场景也会硬凑一个「回执单据」出来。
+ * 条件必填只约束**界面上这一次填写**(如「回执单据」在「需填报」时必选),不该进模型 schema。
+ */
+ private boolean requiredWhenVisible;
+
+ /**
+ * 「隐藏时提交固定值」:本字段因 {@link #visibleWhen} 不成立而不显示时,提交体里照样带上这个值。
+ *
+ * 对齐模块页**两种隐藏口径**中的后者(前者不用本字段):
+ * 形如 {@code visibleWhen("receiptMode", "confirm", "fill")} —— 宿舍通知模块页的
+ * 「宿管到场配合」只在「回执方式 ≠ 仅通知」时出现。多个键之间是「且」。
+ */
+ private Map ★ 与 {@link #required} 的区别:{@code required} 会进**给模型的 schema**,
+ * 而条件必填只是界面这一次填写的约束(宿舍通知的「回执单据」在「需填报」时必选,
+ * 但「仅通知」时模型不该被要求给这个值)。
+ */
+ private boolean requiredWhenVisible;
+
+ /**
+ * 「隐藏时提交固定值」:本字段因 {@link #visibleWhen} 不成立而不显示时,提交体里照样带上这个值。
+ *
+ * ★ 模块页的隐藏有**两种口径**,必须区分,不能一律「不提交」:
+ * ★ 对齐模块页的 {@code v-if} 联动。宿舍通知 {@code NewNotice.vue}:
+ * 不声明 = 恒显示(既有字段的行为完全不变)。可多次调用声明多个条件(之间是「且」)。
+ *
+ * @param field 决定可见性的那个字段名(必须是**同一工具**的另一个参数)
+ * @param values 命中其中任一值即显示(按字符串比较,如 {@code "fill"})
+ */
+ public ToolParam visibleWhen(String field, String... values) {
+ if (StringUtils.isBlank(field) || values == null || values.length == 0) {
+ throw new IllegalArgumentException("visibleWhen 需要「依赖字段名 + 至少一个取值」");
+ }
+ if (this.visibleWhen == null) {
+ this.visibleWhen = new LinkedHashMap<>();
+ }
+ this.visibleWhen.put(field, Arrays.asList(values));
+ return this;
+ }
+
+ /**
+ * 条件必填:仅当本字段因 {@link #visibleWhen} 真的显示在界面上时才要求填写。
+ *
+ * 模块页「需填报」类通知必须选定「回执单据」,但「仅通知」时压根没有这个控件 ⇒
+ * 用 {@code .required} 会让模型在「仅通知」场景也硬凑一个值(required 会进模型 schema)。
+ */
+ public ToolParam requiredWhenVisible() {
+ this.requiredWhenVisible = true;
+ return this;
+ }
+
+ /**
+ * 隐藏时提交固定值:本字段因 {@link #visibleWhen} 不成立而**不显示**时,提交体里带上 {@code value}。
+ *
+ * ★ 只对「模块页隐藏了仍然提交」的字段使用(家校活动 {@code NewActivity.vue}:
+ * {@code quota: form.receiptMode === "none" ? 0 : ...})。宿舍通知这类
+ * 「隐藏即整键不发」的字段**不要**调用它。
+ *
+ * 与 {@link #dft} 的区别:{@code dft} 是用户**看得见**时输入框里的初始值,
+ * 本方法是字段**看不见**时提交体里的占位值,两者互不影响。
+ */
+ public ToolParam hiddenValue(Object value) {
+ this.hiddenValue = value;
+ return this;
+ }
+
/**
* 覆盖附件上传地址。★ 传**模块内相对路径**(如 {@code /api/file/Upload}),
* 模块前缀由编排层统一拼;不传则用模块默认的 {@code /api/base/FileUpload}。
@@ -673,6 +760,16 @@ public class ToolParam {
f.setSwitchOff(switchOff);
f.setRows(rows);
f.setMaxLength(maxLength);
+ // 条件显示 / 条件必填(对齐模块页「回执方式」决定的联动)
+ f.setVisibleWhen(visibleWhen);
+ f.setRequiredWhenVisible(requiredWhenVisible);
+ // 隐藏时提交体里仍带的固定值(对齐模块页「控件隐藏但请求体照发」的那种写法)
+ // ★ 没声明 visibleWhen 的字段恒显示,固定值永远用不上 ⇒ 直接报错,别写成静默死配置
+ if (hiddenValue != null && (visibleWhen == null || visibleWhen.isEmpty())) {
+ throw new IllegalStateException(
+ "参数 " + name + " 声明了 hiddenValue 却没有 visibleWhen —— 字段恒显示,该固定值永远用不上");
+ }
+ f.setHiddenValue(hiddenValue);
// 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼)
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 68a26dd..ef11c2b 100644
--- a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java
+++ b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java
@@ -107,6 +107,8 @@ public final class ActivityTools {
"发布一条家校活动(可要求家长报名)。字段与家校活动模块的「新建活动」页**完全一致**:"
+ "标题、活动分类、主办部门、活动地点、活动时间、报名截止、回执方式、名额上限、"
+ "家长随行、活动费用、接收班级、短信提醒、附件、正文、定时发送、存草稿。"
+ + "其中「名额上限 / 家长随行」只在回执方式为「需报名 / 需确认」时才出现"
+ + "(「仅通知」的表单里不显示这两项,系统按模块页口径提交 0),不要追问。"
+ "调用前必须确认:标题 title、正文 content、活动分类 category、主办部门 deptId、"
+ "活动地点 venue、开始/结束时间 startTime/endTime;"
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
@@ -144,10 +146,16 @@ public final class ActivityTools {
.options(new String[]{"none", "signup", "confirm"},
new String[]{"仅通知", "需报名", "需确认"}),
ToolParam.optional("quota", "integer", "名额上限(0 或留空 = 不限)")
- .label("名额上限").dft("0"),
+ .label("名额上限").dft("0")
+ // ★ 模块页 NewActivity.vue:`showReceiptOpts = receiptMode !== "none"`
+ // 包住「名额上限 / 家长随行」两项 —— 「仅通知」时这两个控件不渲染。
+ // 但**提交体里照旧带 0**(`quota: receiptMode === "none" ? 0 : ...`),
+ // 所以这里要配 hiddenValue(0),不能像宿舍通知那样整键不发。
+ .visibleWhen("receiptMode", "signup", "confirm").hiddenValue(0),
ToolParam.optional("allowAccompany", "integer", "是否允许家长随行")
.label("家长随行").dft("1")
- .options(new String[]{"1", "0"}, new String[]{"允许", "不允许"}),
+ .options(new String[]{"1", "0"}, new String[]{"允许", "不允许"})
+ .visibleWhen("receiptMode", "signup", "confirm").hiddenValue(0),
ToolParam.optional("cost", "number", "活动费用(元,留空=免费)")
.label("活动费用"),
ToolParam.optional("receiveList", "array", "接收班级:班级 id 列表;留空或含 ALL = 全部班级")
diff --git a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java
index 9076dbd..df6572d 100644
--- a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java
+++ b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java
@@ -117,6 +117,9 @@ public final class DormNoticeTools {
+ "字段与宿舍通知模块的「新建通知」页**完全一致**:"
+ "标题、通知类型、发布部门、紧急程度、生效时间、回执截止、回执方式、回执单据、"
+ "接收宿舍、宿管配合、置顶、短信、附件、正文、定时发送、存草稿。"
+ + "★「回执方式」决定后面还有哪些参数(模块页就是这么联动的):"
+ + "仅通知 = 既不要「回执单据」也不要「宿管到场配合」;"
+ + "需确认 = 只多一个「宿管到场配合」;需填报 = 两者都要,且「回执单据」必选。"
+ "调用前必须确认:标题 title、正文 content、通知类型 noticeType、发文部门 deptId;"
+ "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;"
+ "通知类型与发文部门必须来自字典,不要编造。"
@@ -151,9 +154,12 @@ public final class DormNoticeTools {
.label("回执方式").dft("none")
.options(new String[]{"none", "confirm", "fill"},
new String[]{"仅通知", "需确认", "需填报"}),
- ToolParam.optional("formType", "string", "回执单据类型(receiptMode=fill 时必填),来自 dorm.formTemplates 的 type 字段")
+ ToolParam.optional("formType", "string", "回执单据类型(仅「回执方式=需填报」时需要),来自 dorm.formTemplates 的 type 字段")
.label("回执单据")
- .optionsFrom("dorm.formTemplates").optionsKeys("type", "name"),
+ .optionsFrom("dorm.formTemplates").optionsKeys("type", "name")
+ // ★ 模块页 NewNotice.vue:`isFill = receiptMode === "fill"` 时才渲染
+ // 「回执单据」这一项(选单据类型 + 家长端字段预览),且「需填报」必须选定单据。
+ .visibleWhen("receiptMode", "fill").requiredWhenVisible(),
// ★ 接收宿舍 = 模块页那个自写的「选择接收宿舍」弹窗同款控件
// (左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数)。
// 提交口径不变:房间 id 逗号串,或全选哨兵 "ALL"。
@@ -164,9 +170,12 @@ public final class DormNoticeTools {
.pickerSource(PICKER_SOURCE_BUILDING[0], PICKER_SOURCE_BUILDING[1])
.pickerSource(PICKER_SOURCE_ROOM[0], PICKER_SOURCE_ROOM[1])
.join(",").allOption("ALL", "全部宿舍"),
- ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅需回执时有意义)")
+ ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅「回执方式」不是「仅通知」时才需要)")
.label("宿管到场配合").dft("0")
- .options(new String[]{"0", "1"}, new String[]{"否", "是"}),
+ .options(new String[]{"0", "1"}, new String[]{"否", "是"})
+ // ★ 模块页 NewNotice.vue:`showReceiptOpts = receiptMode !== "none"` ——
+ // 「仅通知」时这一项根本不渲染。
+ .visibleWhen("receiptMode", "confirm", "fill"),
ToolParam.optional("topmost", "integer", "是否置顶展示")
.label("置顶展示").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"}),
diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
index 3373e35..1023e1f 100644
--- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
+++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
@@ -121,6 +121,160 @@ class ModuleFormParityTest {
}
}
+ /* ==================== ②b 宿舍通知:字段随「回执方式」联动 ==================== */
+
+ /**
+ * 宿舍通知模块页 {@code NewNotice.vue} 里,「回执方式」决定了下面还显示哪些参数:
+ *
+ * 助手表单原先把这两项一次性全摆出来 —— 「仅通知」档位的用户会看到两个当前场景根本
+ * 不存在的参数,还得自己判断该不该填。这里把口径钉死,并顺带钉住「条件必填**不能**写成
+ * {@code required}」:{@code required} 会进给模型的 schema,于是模型在「仅通知」时
+ * 也会硬凑一个单据类型出来。
+ */
+ @Test
+ void dormPublishReceiptModeDrivesTheVisibleFieldSet() {
+ ToolDef def = registry.get("dorm.publish");
+
+ ToolParam formType = param(def, "formType");
+ assertNotNull(formType, "宿舍通知要有「回执单据」");
+ assertNotNull(formType.getVisibleWhen(), "「回执单据」要声明条件显示");
+ assertEquals(java.util.Collections.singletonList("fill"), formType.getVisibleWhen().get("receiptMode"),
+ "「回执单据」只在「回执方式=需填报」时出现(模块页 v-if=\"isFill\")");
+ assertTrue(formType.isRequiredWhenVisible(),
+ "「需填报」必须选定单据类型,但这是**条件必填**(界面上这一次填写的约束)");
+ assertFalse(formType.isRequired(),
+ "条件必填不能写成 required —— 那会让模型在「仅通知」时也硬凑一个值");
+
+ ToolParam needSupervisor = param(def, "needSupervisor");
+ assertNotNull(needSupervisor, "宿舍通知要有「宿管到场配合」");
+ assertNotNull(needSupervisor.getVisibleWhen(), "「宿管到场配合」要声明条件显示");
+ assertEquals(Arrays.asList("confirm", "fill"), needSupervisor.getVisibleWhen().get("receiptMode"),
+ "「宿管到场配合」在「不是仅通知」时出现(模块页 v-if=\"showReceiptOpts\")");
+
+ // 条件必填不进给模型的 required
+ com.alibaba.fastjson.JSONArray required = def.toOpenAiTool().getJSONObject("function")
+ .getJSONObject("parameters").getJSONArray("required");
+ assertFalse(required.contains("formType"), "条件必填不该出现在给模型的 required 里:" + required);
+
+ // 下发到前端的表单字段也要带上这两份信息(前端据此渲染 / 校验 / 过滤提交体)
+ FormField emitted = formType.toFormField();
+ assertEquals(java.util.Collections.singletonList("fill"), emitted.getVisibleWhen().get("receiptMode"));
+ assertTrue(emitted.isRequiredWhenVisible());
+ }
+
+ /**
+ * 通用不变量:凡声明了条件显示的字段,其依赖必须是**同一工具里真实存在**的字段,
+ * 且所列取值必须是该依赖字段真实的选项。
+ *
+ * ★ 为什么值得单列一条:条件显示写错是**静默失效** —— 依赖字段名打错一个字
+ * ({@code receiptMode} 写成 {@code receiptType}),前端永远判「不显示」,
+ * 字段就此从表单上消失,不报任何错,单模块的字段断言也发现不了。
+ */
+ @Test
+ void everyConditionalFieldDependsOnARealSiblingWithRealValues() {
+ int checked = 0;
+ for (ToolDef def : registry.all()) {
+ for (ToolParam p : def.getParams()) {
+ Map {@code NewActivity.vue}:
+ * ★ 这条与 {@link #dormPublishReceiptModeDrivesTheVisibleFieldSet()} 是**成对的反例**:
+ * 宿舍是「隐藏即整键不发」,活动是「隐藏但照发固定值」。要是照搬一种口径,
+ * 「仅通知」的活动就会在库里留着上次的名额 / 随行值(或反之,多发一个模块页不产出的值)。
+ */
+ @Test
+ void activityReceiptModeHidesQuotaAndAccompanyButStillSubmitsZero() {
+ ToolDef def = registry.get("activity.publish");
+
+ for (String name : new String[]{"quota", "allowAccompany"}) {
+ ToolParam p = param(def, name);
+ assertNotNull(p, "家校活动发布要有 " + name);
+ assertNotNull(p.getVisibleWhen(), name + " 要声明条件显示");
+ assertEquals(Arrays.asList("signup", "confirm"), p.getVisibleWhen().get("receiptMode"),
+ name + " 在「回执方式 ≠ 仅通知」时才出现(模块页 v-if=\"showReceiptOpts\")");
+ assertNotNull(p.getHiddenValue(), name + " 隐藏时要按模块页口径照旧提交 0,不能整键不发");
+ assertEquals(0, ((Number) p.getHiddenValue()).intValue(), name + " 的固定值应为 0");
+ assertFalse(p.isRequired(), name + " 不是必填");
+ assertFalse(p.isRequiredWhenVisible(), name + " 也不是条件必填(模块页这两项没有校验)");
+ }
+
+ // 下发到前端的字段同样要带齐两份信息(前端据此渲染 + 过滤 / 补齐提交体)
+ FormField emitted = param(def, "quota").toFormField();
+ assertEquals(Arrays.asList("signup", "confirm"), emitted.getVisibleWhen().get("receiptMode"));
+ assertEquals(0, ((Number) emitted.getHiddenValue()).intValue());
+
+ // 两种口径必须真的不同:宿舍那两项**没有** hiddenValue
+ assertNull(param(registry.get("dorm.publish"), "formType").getHiddenValue(),
+ "宿舍通知的「回执单据」隐藏时整键不发,不该有 hiddenValue(与家校活动的口径相反)");
+ assertNull(param(registry.get("dorm.publish"), "needSupervisor").getHiddenValue(),
+ "宿舍通知的「宿管到场配合」隐藏时整键不发,不该有 hiddenValue");
+ }
+
+ /**
+ * 通用不变量:{@code hiddenValue}(「隐藏时提交固定值」)只在**声明了 visibleWhen** 的字段上成立;
+ * 且该固定值必须是本字段自己的合法取值(有固定选项时)。
+ *
+ * ★ 为什么值得单列:hiddenValue 写错是**静默的错误数据** —— 字段永远显示时它就是死配置
+ * (用户看不到任何异常);写成一个本字段根本不产出的值(如 allowAccompany=9),
+ * 等于往业务库塞模块页永远不会写的东西,界面上完全看不出来。
+ */
+ @Test
+ void hiddenValueIsOnlyUsedWithVisibilityAndIsALegalValueOfItsOwnField() {
+ int checked = 0;
+ for (ToolDef def : registry.all()) {
+ for (ToolParam p : def.getParams()) {
+ Object hv = p.getHiddenValue();
+ if (hv == null) {
+ continue;
+ }
+ checked++;
+ assertNotNull(p.getVisibleWhen(), def.getName() + " 的「" + p.getName()
+ + "」声明了 hiddenValue 却没有 visibleWhen —— 字段恒显示,这个固定值永远用不上");
+ List
+ *
+ */
+ private Object hiddenValue;
+
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 ea91a2e..681e6ca 100644
--- a/src/main/java/com/yunhai/agent/tool/ToolParam.java
+++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java
@@ -233,6 +233,40 @@ public class ToolParam {
/** 文本最大长度(对齐模块页 {@code maxlength} + 字数统计) */
private Integer maxLength;
+ /**
+ * 条件显示:本字段仅当所列字段取到给定值时渲染。
+ *
+ *
+ *
+ * 一律「不提交」会让「仅通知」的活动在库里留着上次的名额 / 随行值,与模块页提交体不一致。
+ */
+ private Object hiddenValue;
+
public ToolParam() {
}
@@ -530,6 +564,59 @@ public class ToolParam {
return this;
}
+ /**
+ * 条件显示:本字段只在 {@code field} 取到所列**任一**值时渲染、校验与提交。
+ *
+ *
+ * const isFill = computed(() => form.receiptMode === "fill"); // 回执单据
+ * const showReceiptOpts = computed(() => form.receiptMode !== "none"); // 宿管到场配合
+ *
+ * ⇒ {@code .visibleWhen("receiptMode", "fill")} 与
+ * {@code .visibleWhen("receiptMode", "confirm", "fill")}。
+ *
+ *
+ * const isFill = computed(() => form.receiptMode === "fill"); // → 「回执单据」
+ * const showReceiptOpts = computed(() => form.receiptMode !== "none"); // → 「宿管到场配合」
+ *
+ *
+ *
+ * const showReceiptOpts = computed(() => form.receiptMode !== "none"); // 包住这两项
+ * quota: form.receiptMode === "none" ? 0 : Number(form.quota) || 0,
+ * allowAccompany: form.receiptMode === "none" ? 0 : form.allowAccompany ? 1 : 0,
+ *
+ *
+ *