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
@@ -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})在选项就绪后自动选中第一项。
* <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;
/** 说明(来自工具定义,写清格式与含义) */
@@ -94,6 +126,13 @@ public class FormField {
/** 格式示例,前端用作 placeholder,如 2026-10-11 08:00:00 */
private String example;
/**
* 显式的占位提示(优先于 {@link #example})。
* <p>用于对齐业务模块页的原文案,如请假事由的「请输入请假原因.....」——
* 这里不该塞它的示例值(「发烧就医,需在家休息」),那是给模型看的参考,不是给用户看的提示。
*/
private String placeholder;
private String defaultValue;
/**
@@ -619,9 +619,29 @@ public class ToolExecutor {
*
* <p>与模块页 {@code LeaveTimeSection.regenerate()} 口径一致(每天一段,时分沿用基准)。
* 放在执行器里做,浏览器代执行与服务端直调共用同一份逻辑。
*
* <p>★ 若入参里**已经有** {@code continuousLeaveTime}(助手表单让用户逐天微调过,
* 前端会显式发来),则以它为准、不再自行展开 —— 否则用户改好的个别天会被覆盖掉。
*/
private Map<String, Object> expandDailyArgs(ToolDef def, Map<String, Object> args) {
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()) {
Map<String, String> spec = p.getDailyExpandSpec();
if (spec == null || spec.isEmpty()) continue;
@@ -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 {
* <p>作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项;
* ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。
*/
/**
* 依赖的先序字段名(用于联动下拉,如「学生」依赖「班级」)
*/
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() {
}
@@ -207,6 +254,15 @@ public class ToolParam {
return this;
}
/**
* 显式占位提示,照搬业务模块页的原文案(如请假事由的「请输入请假原因.....」)。
* <p>与 {@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})。
* <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} 兜底) */
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;
@@ -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}、后端字典种子一致)。
* <p>权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里自定义),
* 该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给浏览器用用户登录态取实时字典,
* 这份兜底保证「取不到时下拉里也还有标准项」,不会退化成让用户填「类型编号」的数字框。
* 请假类型内置兜底(与 HomeLeave 前端 {@code LEAVE_TYPE_OPTIONS} 的种子一致)。
*
* <p>★ 权威来源是字典接口 {@code GET /api/dict/LeaveTypeOptions}(学校可在「应用设置」里
* 增删改,种子就是下面两项)。该接口需登录会话、服务端取不到 —— 表单会带上下拉并交给
* 浏览器用用户登录态取实时字典,**取到就以学校的为准**(`.optionsFrom` 全部覆盖)。
* 这份兜底只保证「实时字典没取到时下拉不是空的」,避免退化成让用户填「类型编号」的数字框。
*/
private static final String[] LEAVE_TYPE_VALUES = {"1", "2"};
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() {
}
@@ -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} 口径一致):
* 起止时间 + <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() {
return Arrays.asList("请假", "学生请假", "homeleave");