feat(form): 表单支持单选/勾选/快捷时段/逐天微调,请假登记表面对齐模块页
build / build-push (push) Successful in 1m28s

- ToolParam / FormField 新增控件能力:
  radio(单选按钮组)、switch(勾选框+开关值映射)、quickPeriods(快捷时段)、
  dailyExpand(按天展开)、autoPickFirst(默认选中第一项)、
  hideIfSingleOption(单选项隐藏)、placeholder(照搬模块页原文案)
- LeaveTools:
  * leave.submit / leave.register 的 leaveType 改单选按钮组并默认选中第一个
  * 请假时间补齐快捷时段与「连续请假」勾选框(勾上展开连续天数 + 逐天时段)
  * 事由 / 附件对齐模块页文案;学生端 classId 单班隐藏、教师端恒显示
- ToolExecutor.expandDailyArgs 认前端显式发来的 continuousLeaveTime
  (用户在逐天时段里做过微调就以他改的为准,不再按基准覆盖)
- 只读查询因下游 302 失败时也回传 landingPath,让「去业务系统查看」按钮有着落
This commit is contained in:
lijing
2026-10-13 16:15:51 +08:00
parent c16356db6e
commit 53b3eaa39c
7 changed files with 486 additions and 55 deletions
@@ -252,8 +252,12 @@ public class AgentOrchestrator {
ToolCallTrace trace = buildTrace(def, args, tr); ToolCallTrace trace = buildTrace(def, args, tr);
resp.getToolCalls().add(trace); resp.getToolCalls().add(trace);
if (!def.isReadOnly()) { // 写操作 → 去系统里确认结果;只读**失败**(下游 302 / 会话没建起来)→
resp.setWroteSomething(true); // 同样要给「去系统里自己查看」入口,否则模型嘴上说「点下方按钮」而界面没有按钮。
if (!def.isReadOnly() || !tr.isSuccess()) {
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
}
if (StringUtils.isNotBlank(def.getLandingPath())) { if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath()); resp.setLandingPath(def.getLandingPath());
} }
@@ -361,8 +365,12 @@ public class AgentOrchestrator {
} }
auditService.record(session.getSessionId(), caller, def, args, tr); auditService.record(session.getSessionId(), caller, def, args, tr);
resp.getToolCalls().add(buildTrace(def, args, tr)); resp.getToolCalls().add(buildTrace(def, args, tr));
if (!def.isReadOnly()) { // 写操作 → 去系统里确认结果;只读**失败**(如下游 302 / 会话没建起来)→
resp.setWroteSomething(true); // 同样要给「去系统里自己查看」入口,否则模型嘴上说「点下方按钮」而界面没有按钮。
if (!def.isReadOnly() || !tr.isSuccess()) {
if (!def.isReadOnly()) {
resp.setWroteSomething(true);
}
if (StringUtils.isNotBlank(def.getLandingPath())) { if (StringUtils.isNotBlank(def.getLandingPath())) {
resp.setLandingPath(def.getLandingPath()); resp.setLandingPath(def.getLandingPath());
} }
@@ -18,7 +18,7 @@ public class FormField {
/** 中文标签,如「开始时间」 */ /** 中文标签,如「开始时间」 */
private String label; 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; private String widget;
/** 数据类型:string / integer / number / boolean / array */ /** 数据类型:string / integer / number / boolean / array */
@@ -86,6 +86,38 @@ public class FormField {
*/ */
private boolean fileAsObject; private boolean fileAsObject;
/**
* 单选控件({@code widget=radio})在选项就绪后自动选中第一项。
* <p>对齐模块页:{@code LeaveApply.vue} 的「请假类型」一进页面就已选中第一个类型。
*/
private boolean autoPickFirst;
/**
* 选项只有一个时自动填入并隐藏本字段。
* <p>对齐模块页:{@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})。
* <p>对齐模块页 {@code LeaveTimeSection.quickSet()}:沿用已选日期,只换时分秒。
*/
private Map<String, Object> quickPeriods;
/** {@code widget=switch} 勾选时提交的值 */
private String switchOn;
/** {@code widget=switch} 未勾选时提交的值 */
private String switchOff;
/** 多行文本框显示行数 */
private Integer rows;
/** 文本最大长度(前端显示字数统计) */
private Integer maxLength;
private boolean required; private boolean required;
/** 说明(来自工具定义,写清格式与含义) */ /** 说明(来自工具定义,写清格式与含义) */
@@ -94,6 +126,13 @@ public class FormField {
/** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */ /** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */
private String example; private String example;
/**
* 显式的占位提示(优先于 {@link #example})。
* <p>用于对齐业务模块页的原文案,如请假事由的「请输入请假原因.....」——
* 这里不该塞它的示例值(「发烧就医,需在家休息」),那是给模型看的参考,不是给用户看的提示。
*/
private String placeholder;
private String defaultValue; private String defaultValue;
/** /**
@@ -619,9 +619,29 @@ public class ToolExecutor {
* *
* <p>与模块页 {@code LeaveTimeSection.regenerate()} 口径一致(每天一段,时分沿用基准)。 * <p>与模块页 {@code LeaveTimeSection.regenerate()} 口径一致(每天一段,时分沿用基准)。
* 放在执行器里做,浏览器代执行与服务端直调共用同一份逻辑。 * 放在执行器里做,浏览器代执行与服务端直调共用同一份逻辑。
*
* <p>★ 若入参里**已经有** {@code continuousLeaveTime}(助手表单让用户逐天微调过,
* 前端会显式发来),则以它为准、不再自行展开 —— 否则用户改好的个别天会被覆盖掉。
*/ */
private Map<String, Object> expandDailyArgs(ToolDef def, Map<String, Object> args) { private Map<String, Object> expandDailyArgs(ToolDef def, Map<String, Object> args) {
Map<String, Object> extra = new LinkedHashMap<>(); Map<String, Object> 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()) { for (ToolParam p : def.getParams()) {
Map<String, String> spec = p.getDailyExpandSpec(); Map<String, String> spec = p.getDailyExpandSpec();
if (spec == null || spec.isEmpty()) continue; if (spec == null || spec.isEmpty()) continue;
@@ -62,6 +62,9 @@ public class ToolParam {
/** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */ /** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */
private String example; private String example;
/** 显式占位提示(优先于 {@link #example}),用于照搬业务模块页的原文案 */
private String placeholderText;
/** /**
* 控件类型:text / textarea / number / date / datetime / select / multiselect / file; * 控件类型:text / textarea / number / date / datetime / select / multiselect / file;
* 缺省按参数名与类型自动推断。 * 缺省按参数名与类型自动推断。
@@ -148,8 +151,52 @@ public class ToolParam {
* <p>作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项; * <p>作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项;
* ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。 * ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。
*/ */
/**
* 依赖的先序字段名(用于联动下拉,如「学生」依赖「班级」)
*/
private List<String> dependsOn; private List<String> dependsOn;
/**
* 单选控件({@code widget=radio})在选项就绪后**自动选中第一项**。
*
* <p>★ 对齐模块页行为:HomeLeave 的 {@code LeaveApply.vue} 里「请假类型」是单选按钮组,
* 且 {@code watch(leaveTypeOptions, opts => { if (formData.leaveType == null && opts.length)
* formData.leaveType = opts[0].id })} —— 一进页面就已经选中第一个类型。
* 助手的表单若不自动选中,用户就得比模块页多点一步,且容易漏填导致必填校验卡住。
*/
private boolean autoPickFirst;
/**
* 选项只有一个时**自动填入并隐藏本字段**。
*
* <p>★ 对齐模块页行为:{@code LeaveApply.vue} 的「班级」是
* {@code v-if="myClasses.length > 1"} —— 教师只有 1 个班时页面上根本没有这个控件,
* 值在 {@code onMounted} 里直接带上。助手表单同理:单班自动带上、不显示,
* 多班(或选项没取到)才让用户选。
*/
private boolean hideWhenSingleOption;
/**
* 快捷时段按钮组(对齐 {@code LeaveTimeSection.vue} 的全天/上午/下午)。
*
* <p>结构:{@code {start: 开始字段名, end: 结束字段名,
* items: [{label, start, end}, ...]}};{@code start/end} 是**时刻**({@code HH:mm:ss}),
* 点按钮时套用到当前已选日期上(未选日期则用今天)。
*/
private Map<String, Object> 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() { public ToolParam() {
} }
@@ -207,6 +254,15 @@ public class ToolParam {
return this; return this;
} }
/**
* 显式占位提示,照搬业务模块页的原文案(如请假事由的「请输入请假原因.....」)。
* <p>与 {@link #eg} 的区别:{@code eg} 是给模型看的**示例值**,这里纯粹是给用户看的提示。
*/
public ToolParam placeholder(String value) {
this.placeholderText = value;
return this;
}
/** 指定控件类型 */ /** 指定控件类型 */
public ToolParam as(String value) { public ToolParam as(String value) {
this.widget = value; this.widget = value;
@@ -323,6 +379,88 @@ public class ToolParam {
return this; return this;
} }
/**
* 单选按钮组(对齐模块页的 {@code RadioGroup})。
* <p>取值仍走 {@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;
}
/**
* 快捷时段按钮组(全天 / 上午 / 下午 …),点一下同时填好开始与结束**时刻**。
*
* <pre>
* 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")
* </pre>
*
* <p>三元组按「标签、开始时刻、结束时刻」顺序依次传入(个数必须为 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<Map<String, String>> items = new ArrayList<>();
for (int i = 0; i + 2 < labelStartEnd.length; i += 3) {
Map<String, String> it = new LinkedHashMap<>();
it.put("label", labelStartEnd[i]);
it.put("start", labelStartEnd[i + 1]);
it.put("end", labelStartEnd[i + 2]);
items.add(it);
}
Map<String, Object> 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} 兜底) */ /** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
public ToolParam upload(String path) { public ToolParam upload(String path) {
this.uploadPathOverride = path; this.uploadPathOverride = path;
@@ -407,6 +545,7 @@ public class ToolParam {
f.setRequired(required); f.setRequired(required);
f.setDescription(description); f.setDescription(description);
f.setExample(example); f.setExample(example);
f.setPlaceholder(placeholderText);
f.setDefaultValue(defaultValue); f.setDefaultValue(defaultValue);
f.setOptions(optionList()); f.setOptions(optionList());
// 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」 // 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」
@@ -422,6 +561,14 @@ public class ToolParam {
f.setMirrorOf(mirrorLabelOf); f.setMirrorOf(mirrorLabelOf);
f.setDailyExpand(dailyExpandSpec); f.setDailyExpand(dailyExpandSpec);
f.setFileAsObject(fileObject); 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()); f.setOptionsFallback(isUsingFallbackOptions());
return f; return f;
@@ -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)"; private static final String DT_HINT = "格式 yyyy-MM-dd HH:mm:ss(如 2026-10-12 08:00:00)";
/** /**
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS}、后端字典种子一致)。 * 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS} 的种子一致)。
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义), *
* 该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给浏览器用用户登录态取实时字典, * <p>★ 权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里
* 这份兜底保证「取不到时下拉里也还有标准项」,不会退化成让用户填「类型编号」的数字框。 * 增删改,种子就是下面两项)。该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给
* 浏览器用用户登录态取实时字典,**取到就以学校的为准**(`.optionsFrom` 全部覆盖)。
* 这份兜底只保证「实时字典没取到时下拉不是空的」,避免退化成让用户填「类型编号」的数字框。
*/ */
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"}; private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
private static final String[] LEAVE_TYPE_LABELS = {"病假", "事假及其他"}; private static final String[] LEAVE_TYPE_LABELS = {"病假", "事假及其他"};
/**
* 给模型看的请假类型说明。
*
* <p>★ 这里**不能**写死「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() { private LeaveTools() {
} }
@@ -84,8 +103,10 @@ public final class LeaveTools {
.p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"), .p(ToolParam.optional("pageIndex", "integer", "页码,从 1 开始").dft("1"),
ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"), ToolParam.optional("pageSize", "integer", "每页条数,默认 10").dft("10"),
ToolParam.optional("status", "integer", "按状态筛选:0待审核 1已通过 2待撤销 3已撤销 4撤销拒绝 5待销假 6已销假 7销假拒绝 8已拒绝"), ToolParam.optional("status", "integer", "按状态筛选:0待审核 1已通过 2待撤销 3已撤销 4撤销拒绝 5待销假 6已销假 7销假拒绝 8已拒绝"),
ToolParam.optional("leaveType", "integer", "按请假类型筛选:1 病假、2 事假及其他") ToolParam.optional("leaveType", "integer", "按请假类型筛选(编号含义由学校的「应用设置」定义,表单会列出可选项)")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS), .label("请假类型")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
.optionsFrom("leave.types").optionsKeys("k", "v"),
ToolParam.optional("startDate", "string", "开始日期(含),yyyy-MM-dd"), ToolParam.optional("startDate", "string", "开始日期(含),yyyy-MM-dd"),
ToolParam.optional("endDate", "string", "结束日期(含),yyyy-MM-dd"), ToolParam.optional("endDate", "string", "结束日期(含),yyyy-MM-dd"),
ToolParam.optional("keyword", "string", "关键词(学生姓名/事由)")) ToolParam.optional("keyword", "string", "关键词(学生姓名/事由)"))
@@ -103,8 +124,10 @@ public final class LeaveTools {
.p(ToolParam.optional("startDate", "string", "开始日期 yyyy-MM-dd"), .p(ToolParam.optional("startDate", "string", "开始日期 yyyy-MM-dd"),
ToolParam.optional("endDate", "string", "结束日期 yyyy-MM-dd"), ToolParam.optional("endDate", "string", "结束日期 yyyy-MM-dd"),
ToolParam.optional("classId", "integer", "班级 id"), ToolParam.optional("classId", "integer", "班级 id"),
ToolParam.optional("leaveType", "integer", "请假类型:1 病假、2 事假及其他") ToolParam.optional("leaveType", "integer", "请假类型(编号含义由学校的「应用设置」定义,表单会列出可选项)")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)) .label("请假类型")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
.optionsFrom("leave.types").optionsKeys("k", "v"))
.readAt("/stats") .readAt("/stats")
.modId(6)); .modId(6));
@@ -115,30 +138,25 @@ public final class LeaveTools {
+ "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;" + "尽量确认:请假类型 leaveType、开始时间 startDate、结束时间 endDate、事由 remarks;"
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
"POST_JSON", "/api/leave/Save") "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("请假类型") .label("请假类型")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
.optionsFrom("leave.types").optionsKeys("k", "v"), .optionsFrom("leave.types").optionsKeys("k", "v")
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) // 模块页是单选按钮组,且一进页面就已选中第一个类型
.label("开始时间").eg("2026-10-12 08:00:00"), .radio().autoPickFirst())
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) .p(leaveTimeParams())
.label("结束时间").eg("2026-10-12 17:00:00"), .p(ToolParam.required("remarks", "string", "请假事由")
ToolParam.required("remarks", "string", "请假事由") .label("请假事由").as("textarea").rows(4).maxLength(1000)
.label("请假事由").as("textarea").eg("发烧就医,需在家休息"), // placeholder 照搬模块页原文案(eg 只给模型当填写参考)
ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)") .placeholder("请输入请假原因.....")
.label("班级") .eg("发烧就医,需在家休息"),
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"), evidenceParam())
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))
.fix("type", 2) .fix("type", 2)
.fullForm() .fullForm()
.write("/my") .write("/my")
@@ -151,6 +169,7 @@ public final class LeaveTools {
+ "学生自己请假请改用「提交请假申请」,不要用本工具。" + "学生自己请假请改用「提交请假申请」,不要用本工具。"
+ "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。", + "不确定的字段直接省略,系统会弹表单让用户补齐,不要用文字反复追问。",
"POST_JSON", "/api/leave/Save") "POST_JSON", "/api/leave/Save")
// 字段顺序与模块页「请假登记」一致:班级 → 学生 → 请假类型 → 请假时间 → 事由 → 佐证材料
.p(ToolParam.required("classId", "integer", "学生所在班级 id(决定下面「学生」下拉的可选项)") .p(ToolParam.required("classId", "integer", "学生所在班级 id(决定下面「学生」下拉的可选项)")
.label("班级") .label("班级")
.optionsFrom("leave.myClasses").optionsKeys("classId", "className"), .optionsFrom("leave.myClasses").optionsKeys("classId", "className"),
@@ -158,25 +177,17 @@ public final class LeaveTools {
.label("学生") .label("学生")
.optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName") .optionsFrom("leave.classStudents").optionsKeys("stuId", "stuName")
.dependsOn("classId"), .dependsOn("classId"),
ToolParam.required("leaveType", "integer", "请假类型:1 病假、2 事假及其他") ToolParam.required("leaveType", "integer", LEAVE_TYPE_HINT)
.label("请假类型") .label("请假类型")
.fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS) .fallbackOptions(LEAVE_TYPE_VALUES, LEAVE_TYPE_LABELS)
.optionsFrom("leave.types").optionsKeys("k", "v"), .optionsFrom("leave.types").optionsKeys("k", "v")
ToolParam.required("startDate", "string", "请假开始时间," + DT_HINT) .radio().autoPickFirst())
.label("开始时间").eg("2026-10-12 08:00:00"), .p(leaveTimeParams())
ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) .p(ToolParam.required("remarks", "string", "请假事由")
.label("结束时间").eg("2026-10-12 17:00:00"), .label("请假事由").as("textarea").rows(4).maxLength(1000)
ToolParam.required("remarks", "string", "请假事由") .placeholder("请输入请假原因.....")
.label("请假事由").as("textarea").eg("家中有事,需请假一天"), .eg("家中有事,需请假一天"),
ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") evidenceParam())
.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))
.fix("type", 1) .fix("type", 1)
.fullForm() .fullForm()
.write("/register") .write("/register")
@@ -229,6 +240,39 @@ public final class LeaveTools {
return list; return list;
} }
/**
* 「请假时间」区(两个写工具共用,保证与模块页 {@code LeaveTimeSection} 口径一致):
* 起止时间 + <b>快捷时段按钮</b>(全天/上午/下午)+ <b>连续请假勾选框</b> + 连续天数。
*/
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<String> moduleAlias() { public static List<String> moduleAlias() {
return Arrays.asList("请假", "学生请假", "homeleave"); return Arrays.asList("请假", "学生请假", "homeleave");
@@ -96,9 +96,9 @@ class FormFieldWidgetTest {
assertFalse(f.isOptionsFallback()); assertFalse(f.isOptionsFallback());
} }
/** ⑥ 真实工具定义回归:请假类型必须是带中文选项的下拉框,不能退回数字框 */ /** ⑥ 真实工具定义回归:请假类型必须是带中文选项的**单选按钮组**(对齐模块页 RadioGroup) */
@Test @Test
void leaveSubmitTypeIsASelectInRealToolDefinition() { void leaveSubmitTypeIsARadioInRealToolDefinition() {
ToolDef def = LeaveTools.defs().stream() ToolDef def = LeaveTools.defs().stream()
.filter(d -> "leave.submit".equals(d.getName())) .filter(d -> "leave.submit".equals(d.getName()))
.findFirst() .findFirst()
@@ -111,9 +111,76 @@ class FormFieldWidgetTest {
FormField f = leaveType.toFormField(); 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(2, f.getOptions().size());
assertEquals("病假", f.getOptions().get(0).get("label")); assertEquals("病假", f.getOptions().get(0).get("label"));
assertEquals("事假及其他", f.getOptions().get(1).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<java.util.Map<String, String>> items =
(java.util.List<java.util.Map<String, String>>) 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());
}
} }
@@ -154,6 +154,112 @@ class LeaveIntentFormTest {
"模块没配地址时不该下发一个必然打不通的代取地址"); "模块没配地址时不该下发一个必然打不通的代取地址");
} }
/* ==================== 表单呈现:与模块页 LeaveApply.vue 逐项对齐 ==================== */
/**
* 字段顺序与模块页「请假申请」一致:班级 → 请假类型 → 请假时间 → 事由 → 佐证材料。
* <p>顺序不是小事:用户是照着模块页的肌肉记忆填的,顺序错了他会找不到字段。
*/
@Test
void studentFormFieldOrderMatchesModulePage() {
ToolDef submit = registry.get("leave.submit");
java.util.List<String> 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<java.util.Map<String, String>> items =
(java.util.List<java.util.Map<String, String>>) 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());
}
/**
* ★ 请假类型的编号含义<b>不能写死</b>:字典是学校在「应用设置」里自定义的,
* 换一所学校编号含义就变了。给模型的说明里只讲「来源」,不讲具体编号。
*/
@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<String> requiredNames(ToolDef def) { private java.util.List<String> requiredNames(ToolDef def) {