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> visibleWhen; + + /** + * 条件必填:只有当 {@link #visibleWhen} 成立(字段可见)时才要求必填。 + * + *

★ 为什么不能直接用 {@link #required}:{@code required} 会被写进**给模型的 JSON Schema** + * 的 {@code required} 数组里,于是模型在「仅通知」场景也会硬凑一个「回执单据」出来。 + * 条件必填只约束**界面上这一次填写**(如「回执单据」在「需填报」时必选),不该进模型 schema。 + */ + private boolean requiredWhenVisible; + + /** + * 「隐藏时提交固定值」:本字段因 {@link #visibleWhen} 不成立而不显示时,提交体里照样带上这个值。 + * + *

对齐模块页**两种隐藏口径**中的后者(前者不用本字段): + *

+ */ + 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; + /** + * 条件显示:本字段仅当所列字段取到给定值时渲染。 + * + *

形如 {@code visibleWhen("receiptMode", "confirm", "fill")} —— 宿舍通知模块页的 + * 「宿管到场配合」只在「回执方式 ≠ 仅通知」时出现。多个键之间是「且」。 + */ + private Map> visibleWhen; + + /** + * 条件必填:仅当 {@link #visibleWhen} 成立(界面上真的显示了本字段)时才要求必填。 + * + *

★ 与 {@link #required} 的区别:{@code required} 会进**给模型的 schema**, + * 而条件必填只是界面这一次填写的约束(宿舍通知的「回执单据」在「需填报」时必选, + * 但「仅通知」时模型不该被要求给这个值)。 + */ + private boolean requiredWhenVisible; + + /** + * 「隐藏时提交固定值」:本字段因 {@link #visibleWhen} 不成立而不显示时,提交体里照样带上这个值。 + * + *

★ 模块页的隐藏有**两种口径**,必须区分,不能一律「不提交」: + *

+ * 一律「不提交」会让「仅通知」的活动在库里留着上次的名额 / 随行值,与模块页提交体不一致。 + */ + private Object hiddenValue; + public ToolParam() { } @@ -530,6 +564,59 @@ public class ToolParam { return this; } + /** + * 条件显示:本字段只在 {@code field} 取到所列**任一**值时渲染、校验与提交。 + * + *

★ 对齐模块页的 {@code v-if} 联动。宿舍通知 {@code NewNotice.vue}: + *

+     * const isFill         = computed(() => form.receiptMode === "fill");   // 回执单据
+     * const showReceiptOpts = computed(() => form.receiptMode !== "none");  // 宿管到场配合
+     * 
+ * ⇒ {@code .visibleWhen("receiptMode", "fill")} 与 + * {@code .visibleWhen("receiptMode", "confirm", "fill")}。 + * + *

不声明 = 恒显示(既有字段的行为完全不变)。可多次调用声明多个条件(之间是「且」)。 + * + * @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} 里,「回执方式」决定了下面还显示哪些参数: + * + *

+     * const isFill          = computed(() => form.receiptMode === "fill");   // → 「回执单据」
+     * const showReceiptOpts = computed(() => form.receiptMode !== "none");  // → 「宿管到场配合」
+     * 
+ * + *

助手表单原先把这两项一次性全摆出来 —— 「仅通知」档位的用户会看到两个当前场景根本 + * 不存在的参数,还得自己判断该不该填。这里把口径钉死,并顺带钉住「条件必填**不能**写成 + * {@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> spec = p.getVisibleWhen(); + if (spec == null || spec.isEmpty()) { + continue; + } + checked++; + for (Map.Entry> e : spec.entrySet()) { + ToolParam dep = param(def, e.getKey()); + assertNotNull(dep, def.getName() + " 的字段「" + p.getName() + + "」依赖了不存在的字段「" + e.getKey() + "」—— 前端会永远判为不显示,字段静默消失"); + List known = dep.getEnumValues() != null ? dep.getEnumValues() : dep.getFallbackValues(); + assertNotNull(known, def.getName() + " 的联动依赖「" + e.getKey() + + "」没有固定取值,前端判不出可见性"); + for (String v : e.getValue()) { + assertTrue(known.contains(v), def.getName() + " 的字段「" + p.getName() + + "」声明的取值「" + v + "」不在「" + e.getKey() + "」的选项里:" + known); + } + } + } + } + assertTrue(checked >= 2, "至少应覆盖宿舍通知的两处联动,实际 " + checked); + } + + /** + * 家校活动:「名额上限 / 家长随行」也随「回执方式」联动,但隐藏口径与宿舍通知**相反** —— + * 控件不渲染,请求体里照旧带 0。 + * + *

{@code NewActivity.vue}: + *

+     * const showReceiptOpts = computed(() => form.receiptMode !== "none");   // 包住这两项
+     * quota:          form.receiptMode === "none" ? 0 : Number(form.quota) || 0,
+     * allowAccompany: form.receiptMode === "none" ? 0 : form.allowAccompany ? 1 : 0,
+     * 
+ * + *

★ 这条与 {@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 known = p.getEnumValues(); + if (known != null) { + assertTrue(known.contains(String.valueOf(hv)), def.getName() + " 的「" + p.getName() + + "」的 hiddenValue=" + hv + " 不在它自己的选项里:" + known); + } + } + } + assertTrue(checked >= 2, "至少应覆盖家校活动的两处(名额上限 / 家长随行),实际 " + checked); + } + /* ==================== ③ 活动:分类存名称 ==================== */ @Test