From 53b3eaa39c768d2806e9dc10372168be4a8b34d1 Mon Sep 17 00:00:00 2001 From: lijing Date: Tue, 13 Oct 2026 16:15:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(form):=20=E8=A1=A8=E5=8D=95=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8D=95=E9=80=89/=E5=8B=BE=E9=80=89/=E5=BF=AB?= =?UTF-8?q?=E6=8D=B7=E6=97=B6=E6=AE=B5/=E9=80=90=E5=A4=A9=E5=BE=AE?= =?UTF-8?q?=E8=B0=83=EF=BC=8C=E8=AF=B7=E5=81=87=E7=99=BB=E8=AE=B0=E8=A1=A8?= =?UTF-8?q?=E9=9D=A2=E5=AF=B9=E9=BD=90=E6=A8=A1=E5=9D=97=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ToolParam / FormField 新增控件能力: radio(单选按钮组)、switch(勾选框+开关值映射)、quickPeriods(快捷时段)、 dailyExpand(按天展开)、autoPickFirst(默认选中第一项)、 hideIfSingleOption(单选项隐藏)、placeholder(照搬模块页原文案) - LeaveTools: * leave.submit / leave.register 的 leaveType 改单选按钮组并默认选中第一个 * 请假时间补齐快捷时段与「连续请假」勾选框(勾上展开连续天数 + 逐天时段) * 事由 / 附件对齐模块页文案;学生端 classId 单班隐藏、教师端恒显示 - ToolExecutor.expandDailyArgs 认前端显式发来的 continuousLeaveTime (用户在逐天时段里做过微调就以他改的为准,不再按基准覆盖) - 只读查询因下游 302 失败时也回传 landingPath,让「去业务系统查看」按钮有着落 --- .../agent/service/AgentOrchestrator.java | 16 +- .../java/com/yunhai/agent/tool/FormField.java | 41 ++++- .../com/yunhai/agent/tool/ToolExecutor.java | 20 +++ .../java/com/yunhai/agent/tool/ToolParam.java | 147 ++++++++++++++++++ .../yunhai/agent/tool/defs/LeaveTools.java | 138 ++++++++++------ .../com/yunhai/agent/FormFieldWidgetTest.java | 73 ++++++++- .../com/yunhai/agent/LeaveIntentFormTest.java | 106 +++++++++++++ 7 files changed, 486 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index 2765f46..0eec2b8 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -252,8 +252,12 @@ public class AgentOrchestrator { ToolCallTrace trace = buildTrace(def, args, tr); resp.getToolCalls().add(trace); - if (!def.isReadOnly()) { - resp.setWroteSomething(true); + // 写操作 → 去系统里确认结果;只读**失败**(下游 302 / 会话没建起来)→ + // 同样要给「去系统里自己查看」入口,否则模型嘴上说「点下方按钮」而界面没有按钮。 + if (!def.isReadOnly() || !tr.isSuccess()) { + if (!def.isReadOnly()) { + resp.setWroteSomething(true); + } if (StringUtils.isNotBlank(def.getLandingPath())) { resp.setLandingPath(def.getLandingPath()); } @@ -361,8 +365,12 @@ public class AgentOrchestrator { } auditService.record(session.getSessionId(), caller, def, args, tr); resp.getToolCalls().add(buildTrace(def, args, tr)); - if (!def.isReadOnly()) { - resp.setWroteSomething(true); + // 写操作 → 去系统里确认结果;只读**失败**(如下游 302 / 会话没建起来)→ + // 同样要给「去系统里自己查看」入口,否则模型嘴上说「点下方按钮」而界面没有按钮。 + if (!def.isReadOnly() || !tr.isSuccess()) { + if (!def.isReadOnly()) { + resp.setWroteSomething(true); + } if (StringUtils.isNotBlank(def.getLandingPath())) { resp.setLandingPath(def.getLandingPath()); } diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java index af72755..4691187 100644 --- a/src/main/java/com/yunhai/agent/tool/FormField.java +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -18,7 +18,7 @@ public class FormField { /** 中文标签,如「开始时间」 */ private String label; - /** 控件类型:text / textarea / number / date / datetime / select / multiselect / file */ + /** 控件类型:text / textarea / number / date / datetime / select / multiselect / file / scopes / flow / radio / switch */ private String widget; /** 数据类型:string / integer / number / boolean / array */ @@ -86,6 +86,38 @@ public class FormField { */ private boolean fileAsObject; + /** + * 单选控件({@code widget=radio})在选项就绪后自动选中第一项。 + *

对齐模块页:{@code LeaveApply.vue} 的「请假类型」一进页面就已选中第一个类型。 + */ + private boolean autoPickFirst; + + /** + * 选项只有一个时自动填入并隐藏本字段。 + *

对齐模块页:{@code LeaveApply.vue} 的「班级」用 {@code v-if="myClasses.length > 1"}`, + * 单班时不渲染、值直接带上。 + */ + private boolean hideWhenSingleOption; + + /** + * 快捷时段按钮组:{@code {start: 开始字段名, end: 结束字段名, + * items: [{label, start, end}]}};{@code start/end} 为时刻({@code HH:mm:ss})。 + *

对齐模块页 {@code LeaveTimeSection.quickSet()}:沿用已选日期,只换时分秒。 + */ + private Map quickPeriods; + + /** {@code widget=switch} 勾选时提交的值 */ + private String switchOn; + + /** {@code widget=switch} 未勾选时提交的值 */ + private String switchOff; + + /** 多行文本框显示行数 */ + private Integer rows; + + /** 文本最大长度(前端显示字数统计) */ + private Integer maxLength; + private boolean required; /** 说明(来自工具定义,写清格式与含义) */ @@ -94,6 +126,13 @@ public class FormField { /** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */ private String example; + /** + * 显式的占位提示(优先于 {@link #example})。 + *

用于对齐业务模块页的原文案,如请假事由的「请输入请假原因.....」—— + * 这里不该塞它的示例值(「发烧就医,需在家休息」),那是给模型看的参考,不是给用户看的提示。 + */ + private String placeholder; + private String defaultValue; /** diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java index bf3f014..b6689b5 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java +++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java @@ -619,9 +619,29 @@ public class ToolExecutor { * *

与模块页 {@code LeaveTimeSection.regenerate()} 口径一致(每天一段,时分沿用基准)。 * 放在执行器里做,浏览器代执行与服务端直调共用同一份逻辑。 + * + *

★ 若入参里**已经有** {@code continuousLeaveTime}(助手表单让用户逐天微调过, + * 前端会显式发来),则以它为准、不再自行展开 —— 否则用户改好的个别天会被覆盖掉。 */ private Map expandDailyArgs(ToolDef def, Map args) { Map extra = new LinkedHashMap<>(); + + // ① 前端已经算好逐天时段(用户可单天微调)→ 原样采用,只把基准对齐到第一天 + Object explicit = args.get("continuousLeaveTime"); + if (explicit instanceof List && !((List) explicit).isEmpty()) { + Object first = ((List) explicit).get(0); + if (first instanceof Map) { + Object s0 = ((Map) first).get("startDate"); + Object e0 = ((Map) first).get("endDate"); + if (s0 != null && e0 != null) { + extra.put("startDate", String.valueOf(s0)); + extra.put("endDate", String.valueOf(e0)); + } + } + return extra; + } + + // ② 否则按「基准时间 + 天数」自行展开(模型直接调用 / 没走表单时的路径) for (ToolParam p : def.getParams()) { Map spec = p.getDailyExpandSpec(); if (spec == null || spec.isEmpty()) continue; diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java index 42a5339..bb28927 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolParam.java +++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java @@ -62,6 +62,9 @@ public class ToolParam { /** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */ private String example; + /** 显式占位提示(优先于 {@link #example}),用于照搬业务模块页的原文案 */ + private String placeholderText; + /** * 控件类型:text / textarea / number / date / datetime / select / multiselect / file; * 缺省按参数名与类型自动推断。 @@ -148,8 +151,52 @@ public class ToolParam { *

作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项; * ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。 */ + /** + * 依赖的先序字段名(用于联动下拉,如「学生」依赖「班级」) + */ private List dependsOn; + /** + * 单选控件({@code widget=radio})在选项就绪后**自动选中第一项**。 + * + *

★ 对齐模块页行为:HomeLeave 的 {@code LeaveApply.vue} 里「请假类型」是单选按钮组, + * 且 {@code watch(leaveTypeOptions, opts => { if (formData.leaveType == null && opts.length) + * formData.leaveType = opts[0].id })} —— 一进页面就已经选中第一个类型。 + * 助手的表单若不自动选中,用户就得比模块页多点一步,且容易漏填导致必填校验卡住。 + */ + private boolean autoPickFirst; + + /** + * 选项只有一个时**自动填入并隐藏本字段**。 + * + *

★ 对齐模块页行为:{@code LeaveApply.vue} 的「班级」是 + * {@code v-if="myClasses.length > 1"} —— 教师只有 1 个班时页面上根本没有这个控件, + * 值在 {@code onMounted} 里直接带上。助手表单同理:单班自动带上、不显示, + * 多班(或选项没取到)才让用户选。 + */ + private boolean hideWhenSingleOption; + + /** + * 快捷时段按钮组(对齐 {@code LeaveTimeSection.vue} 的全天/上午/下午)。 + * + *

结构:{@code {start: 开始字段名, end: 结束字段名, + * items: [{label, start, end}, ...]}};{@code start/end} 是**时刻**({@code HH:mm:ss}), + * 点按钮时套用到当前已选日期上(未选日期则用今天)。 + */ + private Map quickPeriods; + + /** {@code widget=switch} 的「开」值(勾选时提交的值),如请假 continuousLeave 的 {@code 1} */ + private String switchOn; + + /** {@code widget=switch} 的「关」值(未勾选时提交的值),如 {@code 0} */ + private String switchOff; + + /** 多行文本框显示行数(缺省 3,对齐模块页「请假事由」的 4 行) */ + private Integer rows; + + /** 文本最大长度(对齐模块页 {@code maxlength} + 字数统计) */ + private Integer maxLength; + public ToolParam() { } @@ -207,6 +254,15 @@ public class ToolParam { return this; } + /** + * 显式占位提示,照搬业务模块页的原文案(如请假事由的「请输入请假原因.....」)。 + *

与 {@link #eg} 的区别:{@code eg} 是给模型看的**示例值**,这里纯粹是给用户看的提示。 + */ + public ToolParam placeholder(String value) { + this.placeholderText = value; + return this; + } + /** 指定控件类型 */ public ToolParam as(String value) { this.widget = value; @@ -323,6 +379,88 @@ public class ToolParam { return this; } + /** + * 单选按钮组(对齐模块页的 {@code RadioGroup})。 + *

取值仍走 {@link #options}/{@link #optionsFrom},只是渲染成平铺的单选按钮而不是下拉。 + */ + public ToolParam radio() { + this.widget = "radio"; + return this; + } + + /** + * 勾选框 / 开关控件。 + * + * @param onValue 勾选时提交的值(如 {@code "1"}) + * @param offValue 未勾选时提交的值(如 {@code "0"}) + */ + public ToolParam switchOf(String onValue, String offValue) { + this.widget = "switch"; + this.switchOn = onValue; + this.switchOff = offValue; + return this; + } + + /** 选项就绪后自动选中第一项(模块页「请假类型」的默认行为) */ + public ToolParam autoPickFirst() { + this.autoPickFirst = true; + return this; + } + + /** 选项只有一个时自动填入并隐藏本字段(模块页「班级」:只教一个班时不显示) */ + public ToolParam hideIfSingleOption() { + this.hideWhenSingleOption = true; + return this; + } + + /** + * 快捷时段按钮组(全天 / 上午 / 下午 …),点一下同时填好开始与结束**时刻**。 + * + *

+     * ToolParam.required("startDate", "string", "...")
+     *     .as("datetime")
+     *     .quickPeriods("startDate", "endDate",
+     *             "全天", "08:00:00", "18:00:00",
+     *             "上午", "08:00:00", "12:00:00",
+     *             "下午", "12:00:00", "18:00:00")
+     * 
+ * + *

三元组按「标签、开始时刻、结束时刻」顺序依次传入(个数必须为 3 的倍数)。 + * 与模块页 {@code LeaveTimeSection.quickSet()} 口径一致:**沿用已选的日期**, + * 只把时分秒换掉;还没选日期就按今天。 + */ + public ToolParam quickPeriods(String startField, String endField, String... labelStartEnd) { + if (labelStartEnd == null || labelStartEnd.length < 3 || labelStartEnd.length % 3 != 0) { + throw new IllegalArgumentException("quickPeriods 需要「标签, 开始时刻, 结束时刻」三元组"); + } + List> items = new ArrayList<>(); + for (int i = 0; i + 2 < labelStartEnd.length; i += 3) { + Map it = new LinkedHashMap<>(); + it.put("label", labelStartEnd[i]); + it.put("start", labelStartEnd[i + 1]); + it.put("end", labelStartEnd[i + 2]); + items.add(it); + } + Map spec = new LinkedHashMap<>(); + spec.put("start", startField); + spec.put("end", endField); + spec.put("items", items); + this.quickPeriods = spec; + return this; + } + + /** 多行文本框行数 */ + public ToolParam rows(int value) { + this.rows = value; + return this; + } + + /** 文本最大长度(前端显示字数统计,超出时截断提示) */ + public ToolParam maxLength(int value) { + this.maxLength = value; + return this; + } + /** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */ public ToolParam upload(String path) { this.uploadPathOverride = path; @@ -407,6 +545,7 @@ public class ToolParam { f.setRequired(required); f.setDescription(description); f.setExample(example); + f.setPlaceholder(placeholderText); f.setDefaultValue(defaultValue); f.setOptions(optionList()); // 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」 @@ -422,6 +561,14 @@ public class ToolParam { f.setMirrorOf(mirrorLabelOf); f.setDailyExpand(dailyExpandSpec); f.setFileAsObject(fileObject); + // ★ 表单呈现细节(对齐模块页的控件形态) + f.setAutoPickFirst(autoPickFirst); + f.setHideWhenSingleOption(hideWhenSingleOption); + f.setQuickPeriods(quickPeriods); + f.setSwitchOn(switchOn); + f.setSwitchOff(switchOff); + f.setRows(rows); + f.setMaxLength(maxLength); // 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户 f.setOptionsFallback(isUsingFallbackOptions()); return f; diff --git a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java index 22e1638..2c2c1a8 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java @@ -35,15 +35,34 @@ public final class LeaveTools { private static final String DT_HINT = "格式 yyyy-MM-dd HH:mm:ss(如 2026-10-12 08:00:00)"; /** - * 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。 - *

权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义), - * 该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给浏览器用用户登录态取实时字典, - * 这份兜底保证「取不到时下拉里也还有标准项」,不会退化成让用户填「类型编号」的数字框。 + * 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS} 的种子一致)。 + * + *

★ 权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里 + * 增删改,种子就是下面两项)。该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给 + * 浏览器用用户登录态取实时字典,**取到就以学校的为准**(`.optionsFrom` 全部覆盖)。 + * 这份兜底只保证「实时字典没取到时下拉不是空的」,避免退化成让用户填「类型编号」的数字框。 */ private static final String[] LEAVE_TYPE_VALUES = {"1", "2"}; private static final String[] LEAVE_TYPE_LABELS = {"病假", "事假及其他"}; + /** + * 给模型看的请假类型说明。 + * + *

★ 这里**不能**写死「1 病假、2 事假及其他」的编号含义 —— 编号是学校自定义的, + * 换一所学校含义就变了。模型也不需要知道编号:表单的「请假类型」下拉会带出实时字典。 + */ + private static final String LEAVE_TYPE_HINT = + "请假类型编号(来自学校「应用设置」里配置的字典,不同学校编号含义可能不同);" + + "不确定就省略,表单会列出当前学校可选类型让你选"; + + /** 请假时间快捷时段(对齐模块页 LeaveTimeSection 的全天 / 上午 / 下午按钮) */ + private static final String[] QUICK_PERIODS = { + "全天", "08:00:00", "18:00:00", + "上午", "08:00:00", "12:00:00", + "下午", "12:00:00", "18:00:00", + }; + private LeaveTools() { } @@ -84,8 +103,10 @@ public final class LeaveTools { .p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"), ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"), ToolParam.optional("status", "integer", "按状态筛选:0待审核 1已通过 2待撤销 3已撤销 4撤销拒绝 5待销假 6已销假 7销假拒绝 8已拒绝"), - ToolParam.optional("leaveType", "integer", "按请假类型筛选:1 病假、2 事假及其他") - .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS), + ToolParam.optional("leaveType", "integer", "按请假类型筛选(编号含义由学校的「应用设置」定义,表单会列出可选项)") + .label("请假类型") + .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) + .optionsFrom("leave.types").optionsKeys("k", "v"), ToolParam.optional("startDate", "string", "开始日期(含),yyyy-MM-dd"), ToolParam.optional("endDate", "string", "结束日期(含),yyyy-MM-dd"), ToolParam.optional("keyword", "string", "关键词(学生姓名/事由)")) @@ -103,8 +124,10 @@ public final class LeaveTools { .p(ToolParam.optional("startDate", "string", "开始日期 yyyy-MM-dd"), ToolParam.optional("endDate", "string", "结束日期 yyyy-MM-dd"), ToolParam.optional("classId", "integer", "班级 id"), - ToolParam.optional("leaveType", "integer", "请假类型:1 病假、2 事假及其他") - .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)) + ToolParam.optional("leaveType", "integer", "请假类型(编号含义由学校的「应用设置」定义,表单会列出可选项)") + .label("请假类型") + .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) + .optionsFrom("leave.types").optionsKeys("k", "v")) .readAt("/stats") .modId(6)); @@ -115,30 +138,25 @@ public final class LeaveTools { + "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;" + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", "POST_JSON", "/api/leave/Save") - .p(ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他") + // 字段顺序与模块页「请假申请」完全一致:班级 → 请假类型 → 请假时间 → 事由 → 佐证材料 + .p(ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)") + .label("班级") + .optionsFrom("leave.myClasses").optionsKeys("classId", "className") + // 模块页:只有多个班级时才显示「班级」,单班自动带上 + .hideIfSingleOption(), + ToolParam.required("leaveType", "integer", LEAVE_TYPE_HINT) .label("请假类型") .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) - .optionsFrom("leave.types").optionsKeys("k", "v"), - ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) - .label("开始时间").eg("2026-10-12 08:00:00"), - ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) - .label("结束时间").eg("2026-10-12 17:00:00"), - ToolParam.required("remarks", "string", "请假事由") - .label("请假事由").as("textarea").eg("发烧就医,需在家休息"), - ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)") - .label("班级") - .optionsFrom("leave.myClasses").optionsKeys("classId", "className"), - ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") - .label("连续请假").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}) - // 选「是」时按下面的连续天数把起止时间复制成逐天时间段, - // 生成模块要的 continuousLeaveTime(与模块页 LeaveTimeSection 一致) - .expandDaily("continuousLeaveNum", "startDate", "endDate"), - ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)") - .label("连续天数").dft("1"), - // ★ 模块传的是 number[](JSON 数组),不是逗号串,故不声明 join - ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)") - .label("佐证材料").as("file").multiple(true)) + .optionsFrom("leave.types").optionsKeys("k", "v") + // 模块页是单选按钮组,且一进页面就已选中第一个类型 + .radio().autoPickFirst()) + .p(leaveTimeParams()) + .p(ToolParam.required("remarks", "string", "请假事由") + .label("请假事由").as("textarea").rows(4).maxLength(1000) + // placeholder 照搬模块页原文案(eg 只给模型当填写参考) + .placeholder("请输入请假原因.....") + .eg("发烧就医,需在家休息"), + evidenceParam()) .fix("type", 2) .fullForm() .write("/my") @@ -151,6 +169,7 @@ public final class LeaveTools { + "学生自己请假请改用「提交请假申请」,不要用本工具。" + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", "POST_JSON", "/api/leave/Save") + // 字段顺序与模块页「请假登记」一致:班级 → 学生 → 请假类型 → 请假时间 → 事由 → 佐证材料 .p(ToolParam.required("classId", "integer", "学生所在班级 id(决定下面「学生」下拉的可选项)") .label("班级") .optionsFrom("leave.myClasses").optionsKeys("classId", "className"), @@ -158,25 +177,17 @@ public final class LeaveTools { .label("学生") .optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName") .dependsOn("classId"), - ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他") + ToolParam.required("leaveType", "integer", LEAVE_TYPE_HINT) .label("请假类型") .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) - .optionsFrom("leave.types").optionsKeys("k", "v"), - ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) - .label("开始时间").eg("2026-10-12 08:00:00"), - ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) - .label("结束时间").eg("2026-10-12 17:00:00"), - ToolParam.required("remarks", "string", "请假事由") - .label("请假事由").as("textarea").eg("家中有事,需请假一天"), - ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") - .label("连续请假").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}) - .expandDaily("continuousLeaveNum", "startDate", "endDate"), - ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)") - .label("连续天数").dft("1"), - // ★ 模块传的是 number[](JSON 数组),不是逗号串,故不声明 join - ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)") - .label("佐证材料").as("file").multiple(true)) + .optionsFrom("leave.types").optionsKeys("k", "v") + .radio().autoPickFirst()) + .p(leaveTimeParams()) + .p(ToolParam.required("remarks", "string", "请假事由") + .label("请假事由").as("textarea").rows(4).maxLength(1000) + .placeholder("请输入请假原因.....") + .eg("家中有事,需请假一天"), + evidenceParam()) .fix("type", 1) .fullForm() .write("/register") @@ -229,6 +240,39 @@ public final class LeaveTools { return list; } + /** + * 「请假时间」区(两个写工具共用,保证与模块页 {@code LeaveTimeSection} 口径一致): + * 起止时间 + 快捷时段按钮(全天/上午/下午)+ 连续请假勾选框 + 连续天数。 + */ + private static ToolParam[] leaveTimeParams() { + return new ToolParam[]{ + ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) + .label("开始时间").eg("2026-10-12 08:00:00") + .as("datetime") + // 与模块页一致:点「全天 / 上午 / 下午」直接填好时段的时分秒(日期沿用已选的) + .quickPeriods("startDate", "endDate", QUICK_PERIODS), + ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) + .label("结束时间").eg("2026-10-12 17:00:00") + .as("datetime"), + ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") + .label("连续请假").dft("0") + // 模块页是一个勾选框(勾上才展开天数与逐天时段),不是「是/否」下拉 + .switchOf("1", "0") + // 勾选时按天数把起止时间复制成逐天时间段,生成 continuousLeaveTime + .expandDaily("continuousLeaveNum", "startDate", "endDate"), + ToolParam.optional("continuousLeaveNum", "integer", "连续天数(1~30,仅勾选「连续请假」时有效)") + .label("连续天数").dft("1"), + }; + } + + /** 佐证材料(附件):模块传的是 {@code number[]}(JSON 数组),不是逗号串,故不声明 join */ + private static ToolParam evidenceParam() { + return ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到,可多份)") + .label("佐证材料").as("file").multiple(true) + // 与模块页附件区下方的说明一致 + .placeholder("可选,大小不超过 200MB"); + } + /** 供文档/目录使用 */ public static List moduleAlias() { return Arrays.asList("请假", "学生请假", "homeleave"); diff --git a/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java b/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java index 0d30a36..919f59f 100644 --- a/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java +++ b/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java @@ -96,9 +96,9 @@ class FormFieldWidgetTest { assertFalse(f.isOptionsFallback()); } - /** ⑥ 真实工具定义回归:请假类型必须是带中文选项的下拉框,不能退回数字框 */ + /** ⑥ 真实工具定义回归:请假类型必须是带中文选项的**单选按钮组**(对齐模块页 RadioGroup) */ @Test - void leaveSubmitTypeIsASelectInRealToolDefinition() { + void leaveSubmitTypeIsARadioInRealToolDefinition() { ToolDef def = LeaveTools.defs().stream() .filter(d -> "leave.submit".equals(d.getName())) .findFirst() @@ -111,9 +111,76 @@ class FormFieldWidgetTest { FormField f = leaveType.toFormField(); - assertEquals("select", f.getWidget()); + // ★ 2026-10-13:从下拉改为单选按钮组 —— 模块页 LeaveApply.vue 用的就是 RadioGroup, + // 且进页面即选中第一项,助手表单要一致。 + assertEquals("radio", f.getWidget()); + assertTrue(f.isAutoPickFirst(), "选项就绪后应自动选中第一项(模块页的默认行为)"); + assertEquals("leave.types", f.getOptionsFrom(), "选项仍走学校自定义的实时字典"); assertEquals(2, f.getOptions().size()); assertEquals("病假", f.getOptions().get(0).get("label")); assertEquals("事假及其他", f.getOptions().get(1).get("label")); } + + /** ⑦ 单选控件的值与 label 必须同类型(前端 Radio 用严格比较判定选中态) */ + @Test + void radioOptionValuesStayStringsForStrictComparison() { + ToolParam p = ToolParam.required("leaveType", "integer", "请假类型") + .radio().autoPickFirst() + .fallbackOptions(new String[]{"1", "2"}, new String[]{"病假", "事假及其他"}); + + FormField f = p.toFormField(); + + assertEquals("radio", f.getWidget()); + assertEquals("1", f.getOptions().get(0).get("value"), "选项值必须是字符串,否则前端单选按钮选不上"); + assertTrue(f.isAutoPickFirst()); + } + + /** ⑧ 勾选框:连续请假用 switch(模块页是 Checkbox,不是「是/否」下拉) */ + @Test + void switchWidgetCarriesOnOffValues() { + ToolParam p = ToolParam.optional("continuousLeave", "integer", "是否连续请假") + .switchOf("1", "0"); + + FormField f = p.toFormField(); + + assertEquals("switch", f.getWidget()); + assertEquals("1", f.getSwitchOn()); + assertEquals("0", f.getSwitchOff()); + } + + /** ⑨ 快捷时段:三元组按「标签, 开始, 结束」解析,个数不是 3 的倍数要当场报错 */ + @Test + void quickPeriodsParsesTriplesAndRejectsBadArity() { + ToolParam p = ToolParam.required("startDate", "string", "开始时间") + .quickPeriods("startDate", "endDate", + "全天", "08:00:00", "18:00:00", + "上午", "08:00:00", "12:00:00", + "下午", "12:00:00", "18:00:00"); + + FormField f = p.toFormField(); + + assertEquals("startDate", f.getQuickPeriods().get("start")); + assertEquals("endDate", f.getQuickPeriods().get("end")); + @SuppressWarnings("unchecked") + java.util.List> items = + (java.util.List>) f.getQuickPeriods().get("items"); + assertEquals(3, items.size()); + assertEquals("全天", items.get(0).get("label")); + assertEquals("08:00:00", items.get(0).get("start")); + assertEquals("18:00:00", items.get(0).get("end")); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> ToolParam.required("s", "string", "").quickPeriods("s", "e", "仅一个标签")); + } + + /** ⑩ 多行文本框的行数与字数上限也要下发(对齐模块页「请假事由」的 4 行 / 1000 字) */ + @Test + void textareaRowsAndMaxLengthArePassedThrough() { + FormField f = ToolParam.required("remarks", "string", "请假事由") + .as("textarea").rows(4).maxLength(1000).toFormField(); + + assertEquals("textarea", f.getWidget()); + assertEquals(4, f.getRows().intValue()); + assertEquals(1000, f.getMaxLength().intValue()); + } } diff --git a/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java b/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java index 6f04eb1..06b8efa 100644 --- a/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java +++ b/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java @@ -154,6 +154,112 @@ class LeaveIntentFormTest { "模块没配地址时不该下发一个必然打不通的代取地址"); } + /* ==================== 表单呈现:与模块页 LeaveApply.vue 逐项对齐 ==================== */ + + /** + * 字段顺序与模块页「请假申请」一致:班级 → 请假类型 → 请假时间 → 事由 → 佐证材料。 + *

顺序不是小事:用户是照着模块页的肌肉记忆填的,顺序错了他会找不到字段。 + */ + @Test + void studentFormFieldOrderMatchesModulePage() { + ToolDef submit = registry.get("leave.submit"); + java.util.List order = submit.getParams().stream() + .map(ToolParam::getName) + .collect(java.util.stream.Collectors.toList()); + + assertEquals(Arrays.asList("classId", "leaveType", "startDate", "endDate", + "continuousLeave", "continuousLeaveNum", "remarks", "fileIds"), order, + "字段顺序必须与 HomeLeave 的 LeaveApply.vue 一致"); + } + + /** + * 「班级」只在多个班时才显示 —— 模块页是 {@code v-if="myClasses.length > 1"}`, + * 单班时值自动带上、控件根本不渲染。 + */ + @Test + void studentClassPickerHidesItselfWhenOnlyOneClass() { + ToolParam classId = param(registry.get("leave.submit"), "classId"); + assertTrue(classId.isHideWhenSingleOption(), + "只教一个班时不该再让用户选一次(与模块页一致)"); + assertEquals("classId", classId.getOptionValueKey()); + assertEquals("className", classId.getOptionLabelKey()); + } + + /** + * 请假时间要带「全天 / 上午 / 下午」快捷时段 —— 模块页 {@code LeaveTimeSection} 的 + * {@code quickSet()},点一下按已选日期补齐时分秒。 + */ + @Test + void leaveTimeHasQuickPeriodButtons() { + ToolParam startDate = param(registry.get("leave.submit"), "startDate"); + assertNotNull(startDate.getQuickPeriods(), "开始时间应带快捷时段按钮组"); + assertEquals("startDate", startDate.getQuickPeriods().get("start")); + assertEquals("endDate", startDate.getQuickPeriods().get("end")); + @SuppressWarnings("unchecked") + java.util.List> items = + (java.util.List>) startDate.getQuickPeriods().get("items"); + assertEquals(Arrays.asList("全天", "上午", "下午"), + items.stream().map(i -> i.get("label")).collect(java.util.stream.Collectors.toList()), + "三个快捷时段要与模块页同名同序"); + assertEquals("08:00:00", items.get(0).get("start")); + assertEquals("18:00:00", items.get(0).get("end")); + assertEquals("12:00:00", items.get(1).get("end"), "上午到 12:00 截止"); + assertEquals("12:00:00", items.get(2).get("start"), "下午从 12:00 开始"); + } + + /** 「连续请假」是勾选框(勾上才展开连续天数与逐天时段),不是「是/否」下拉 */ + @Test + void continuousLeaveIsACheckboxNotASelect() { + ToolParam cl = param(registry.get("leave.submit"), "continuousLeave"); + assertEquals("switch", cl.resolveWidget()); + assertEquals("1", cl.getSwitchOn()); + assertEquals("0", cl.getSwitchOff()); + assertNotNull(cl.getDailyExpandSpec(), "勾选后要能按天数展开逐天时段"); + assertEquals("continuousLeaveNum", cl.getDailyExpandSpec().get("count")); + } + + /** 事由的呈现细节也要对齐:模块页是 4 行、最多 1000 字、带字数统计 */ + @Test + void remarksMatchesModulePageTextarea() { + ToolParam remarks = param(registry.get("leave.submit"), "remarks"); + assertEquals("textarea", remarks.resolveWidget()); + assertEquals(Integer.valueOf(4), remarks.getRows()); + assertEquals(Integer.valueOf(1000), remarks.getMaxLength()); + } + + /** + * ★ 请假类型的编号含义不能写死:字典是学校在「应用设置」里自定义的, + * 换一所学校编号含义就变了。给模型的说明里只讲「来源」,不讲具体编号。 + */ + @Test + void leaveTypeDescriptionDoesNotHardcodeSchoolSpecificNumbers() { + for (String tool : Arrays.asList("leave.submit", "leave.register")) { + ToolParam leaveType = param(registry.get(tool), "leaveType"); + String desc = leaveType.getDescription(); + assertFalse(desc.contains("1 病假"), + tool + " 的请假类型说明不该写死「1 病假」——编号是学校自定义的"); + assertFalse(desc.contains("2 事假"), + tool + " 的请假类型说明不该写死「2 事假」——编号是学校自定义的"); + assertEquals("leave.types", leaveType.getOptionsFrom(), + "选项必须走学校自定义的实时字典接口"); + } + } + + /** 两个写工具的「请假时间 + 事由 + 附件」区完全一致(同一份 helper 生成) */ + @Test + void bothWriteFlowsShareTheSameTimeAndEvidenceParams() { + for (String tool : Arrays.asList("leave.submit", "leave.register")) { + ToolDef def = registry.get(tool); + assertEquals("datetime", param(def, "startDate").resolveWidget()); + assertEquals("datetime", param(def, "endDate").resolveWidget()); + assertNotNull(param(def, "startDate").getQuickPeriods(), tool + " 缺快捷时段"); + assertEquals("switch", param(def, "continuousLeave").resolveWidget()); + assertEquals("radio", param(def, "leaveType").resolveWidget()); + assertTrue(param(def, "leaveType").isAutoPickFirst(), tool + " 的请假类型应默认选中第一项"); + assertEquals("file", param(def, "fileIds").resolveWidget()); + } + } + /* ==================== 辅助 ==================== */ private java.util.List requiredNames(ToolDef def) {