build / build-push (push) Successful in 1m2s
把 5 个模块「新建/发布」页的字段与提交体逐个核对后,补齐此前缺失或形态不符的部分:
- 部门名镜像:公告/宿舍/活动三处在模块页是「字典 id + 反查名称」一起提交。
新增 mirrorLabelOf 机制 —— 该字段不渲染控件、不进模型 schema,提交时按源字段
选中项的显示名填充(deptName)
- 公告审批流:新增 flow 控件(widget=flow)+ 教师组织树字典 notice.teaTree,
提交 flow:[{name,vote,approverTeaIds}];publishType 值域修正为 direct/approve;
存草稿改用模块自己的独立接口 notice.saveDraft(POST /api/notice/SaveDraft)
- 活动分类:category 提交的是**分类名称**(模块页 :value="String(c.v)"),
optionsKeys 由 ("k","v") 改为 ("v","v")
- 请假附件:fileIds 在 HomeLeave 是 number[](JSON 数组),去掉逗号串拼接;
连续请假按 continuousLeaveNum 逐天展开成 continuousLeaveTime[](服务端/浏览器同源)
- 作业发布(整块新增):homework.publish / schedule / published / drafts /
teachingSubjects / classTree。模块没有一步发布的接口,用 ToolDef.then 声明两步提交
(Create 落草稿 → Publish | Schedule);附件是对象数组
attachments:[{fileId,fileUrl,fileType,fileName,sort}],上传走 file 域
/coursehomework/api/file/Upload
- 执行器:新增 followUp 两步提交(浏览器侧同源)、数组参数给标量时包成单元素数组、
首步响应 data 取资源 id(对象形态取 .id,避免拼出 /homeworks/{"id":1}/publish)
- 新增 ModuleFormParityTest(13 项)逐条锁住上述契约
回归:mvn clean package → Tests run: 103, Failures: 0, Errors: 0, BUILD SUCCESS
真机:headless Chrome 走「表单 → 浏览器代执行 → 模块接口」,50 项断言全绿
454 lines
18 KiB
Java
454 lines
18 KiB
Java
package com.yunhai.agent.tool;
|
||
|
||
import com.alibaba.fastjson.JSONArray;
|
||
import com.alibaba.fastjson.JSONObject;
|
||
import lombok.Data;
|
||
import org.apache.commons.lang3.StringUtils;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.Arrays;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
|
||
/**
|
||
* 工具参数定义。一份声明,三处复用:
|
||
*
|
||
* <ol>
|
||
* <li>生成给大模型的 JSON Schema({@link #toSchema()})</li>
|
||
* <li>入参必填校验</li>
|
||
* <li><b>生成让用户直接填写的表单</b>({@link #toFormField()})—— 信息不全时不再靠模型口头追问,
|
||
* 而是把字段的中文名、格式示例、可选值直接摆到界面上</li>
|
||
* </ol>
|
||
*/
|
||
@Data
|
||
public class ToolParam {
|
||
|
||
/** 参数名(与后端接口参数名严格一致,大小写敏感) */
|
||
private String name;
|
||
|
||
/** JSON Schema 类型:string / integer / number / boolean / array */
|
||
private String type = "string";
|
||
|
||
/** 给模型看的说明(越具体越好,写明格式与取值范围) */
|
||
private String description;
|
||
|
||
private boolean required;
|
||
|
||
/** 枚举取值(非空时会在描述与 schema 里同时体现,帮助模型少犯错) */
|
||
private List<String> enumValues;
|
||
|
||
/** 枚举的中文名,与 enumValues 一一对应(表单下拉展示用;缺省时直接显示值) */
|
||
private List<String> enumLabels;
|
||
|
||
/**
|
||
* 内置兜底枚举:只在「学校可自定义的字典」上使用,是 {@link #optionsFrom} 取不到实时值时的顶替品。
|
||
* <p>有了它,下游字典接口不通时下拉框仍然是下拉框,而不是退化成让用户填「请假类型编号」的数字框。
|
||
* 与 {@link #enumValues} 的区别:enumValues 是权威定义(如审批结论),本字段是「大概是这样,以实时字典为准」。
|
||
*/
|
||
private List<String> fallbackValues;
|
||
|
||
/** 内置兜底枚举的中文名,与 fallbackValues 一一对应 */
|
||
private List<String> fallbackLabels;
|
||
|
||
/** 默认值(模型没给时兜底) */
|
||
private String defaultValue;
|
||
|
||
/* ==================== 表单渲染元数据 ==================== */
|
||
|
||
/** 字段中文标签(表单里显示,如「开始时间」);缺省回退到 name */
|
||
private String label;
|
||
|
||
/** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */
|
||
private String example;
|
||
|
||
/**
|
||
* 控件类型:text / textarea / number / date / datetime / select / multiselect / file;
|
||
* 缺省按参数名与类型自动推断。
|
||
*
|
||
* <ul>
|
||
* <li>{@code multiselect}:多选下拉(配合 {@code type=array}),用于「接收班级 / 宿舍 / 审批人」这类
|
||
* 模块里是多选的字段。</li>
|
||
* <li>{@code file}:附件上传(对齐模块页的「添加附件」),上传后得到 fileid 列表。</li>
|
||
* </ul>
|
||
*/
|
||
private String widget;
|
||
|
||
/**
|
||
* 数组值的序列化分隔符(仅 {@code POST_JSON} 生效)。
|
||
*
|
||
* <p>★ 为什么需要:模块的很多字段在 wire 上是「逗号串」而不是 JSON 数组
|
||
* (如宿舍通知 {@code receiveList="1,2,3"}、附件 {@code fileIds="11,12"}),
|
||
* 但表单里用户是多选的(天然是数组)。声明 {@code .join(",")} 后,执行器会在发请求前
|
||
* 把数组拼成模块要的字符串,避免"页面里选得到、提交后端判非法"。
|
||
*/
|
||
private String joinSeparator;
|
||
|
||
/** {@code widget=file} 时是否允许多个附件(缺省 true);对齐模块页 AttachmentUpload 的 multiple */
|
||
private Boolean multiple;
|
||
|
||
/**
|
||
* 「全部」哨兵值。
|
||
*
|
||
* <p>★ 用途:模块的接收对象多选里都有一个「全部班级 / 全部宿舍」开关,wire 上是 {@code "ALL"}。
|
||
* 声明后:① 表单把它作为第一个选项摆进多选;② 提交时若用户既没选具体项、又没选它,
|
||
* 视作「全部」(保持"默认发全体"的语义);③ 若同时选了具体项与它,以**具体项**为准(折叠掉它)。
|
||
*/
|
||
private String allToken;
|
||
|
||
/** 「全部」选项在表单里显示的中文名,如「全部班级」/「全部宿舍」 */
|
||
private String allTokenLabel;
|
||
|
||
/**
|
||
* 「推送范围」控件(widget=scopes)的节点类型。
|
||
*
|
||
* <p>校园公告的接收对象 {@code pushScopes} 在 wire 上是
|
||
* {@code [{scopeType,scopeId,scopeName}]}(1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员)。
|
||
* 声明后前端把多选结果拼成这个对象数组;选「全部」则发出 {@code [{scopeType:1,scopeId:0,scopeName:"全部班级"}]}。
|
||
*/
|
||
private Integer scopeType;
|
||
|
||
/** 「镜像字段」指向的源字段名(隐藏字段,提交时填入源字段选中项的显示名,如 deptId → deptName) */
|
||
private String mirrorLabelOf;
|
||
|
||
/** 「连续请假」展开说明:{@code {count,start,end}} 三个字段名(见 {@link #expandDaily}) */
|
||
private Map<String, String> dailyExpandSpec;
|
||
|
||
/** {@code widget=file} 时提交附件对象数组而非 fileId 数组(作业模块 attachments) */
|
||
private boolean fileObject;
|
||
|
||
/** 附件上传地址覆盖(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
|
||
private String uploadPathOverride;
|
||
|
||
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
|
||
private boolean hidden;
|
||
|
||
/**
|
||
* 该字段的下拉选项来自哪个只读工具(如 {@code leave.types})。
|
||
* <p>服务端在生成表单时会先执行这个工具,把返回的字典项填成下拉选项 ——
|
||
* 用户就不用去猜「病假是几号」了。执行失败则回退为普通输入框,不影响主流程。
|
||
*/
|
||
private String optionsFrom;
|
||
|
||
/**
|
||
* 选项取自字典返回对象的哪个字段(如 {@code classId} / {@code stuId})。
|
||
*
|
||
* <p>★ 为什么必须显式声明:模块字典返回的是业务 VO({@code TeachingClassVo{classId,className}}、
|
||
* {@code ClassStudentVo{stuId,stuName}}),字段名五花八门,靠「猜常见键名」不可靠。
|
||
* 显式声明后,前端在浏览器里取到原始数据也能**确定性地**映射成下拉项。
|
||
*/
|
||
private String optionValueKey;
|
||
|
||
/** 选项显示名取自字典返回对象的哪个字段(如 {@code className} / {@code stuName}) */
|
||
private String optionLabelKey;
|
||
|
||
/**
|
||
* 本字段的选项依赖哪些其它字段(如「学生」下拉依赖先选好「班级」)。
|
||
*
|
||
* <p>作用有两个:① 生成表单时把依赖名带给前端,前端据此清空/重新拉取选项;
|
||
* ② 生成「浏览器代取选项」的地址时,把依赖字段位置留成占位符({@code ?classId={{classId}}})。
|
||
*/
|
||
private List<String> dependsOn;
|
||
|
||
public ToolParam() {
|
||
}
|
||
|
||
public ToolParam(String name, String type, String description, boolean required) {
|
||
this.name = name;
|
||
this.type = type;
|
||
this.description = description;
|
||
this.required = required;
|
||
}
|
||
|
||
public static ToolParam required(String name, String type, String description) {
|
||
return new ToolParam(name, type, description, true);
|
||
}
|
||
|
||
public static ToolParam optional(String name, String type, String description) {
|
||
return new ToolParam(name, type, description, false);
|
||
}
|
||
|
||
public ToolParam enums(String... values) {
|
||
this.enumValues = Arrays.asList(values);
|
||
return this;
|
||
}
|
||
|
||
/** 枚举值 + 中文名(一一对应),表单会渲染成带中文选项的下拉框 */
|
||
public ToolParam options(String[] values, String[] labels) {
|
||
this.enumValues = Arrays.asList(values);
|
||
this.enumLabels = Arrays.asList(labels);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 内置兜底选项:配合 {@link #optionsFrom} 使用。
|
||
* <p>实时字典取到 → 用实时值;取不到 → 用这份兜底,下拉框不至于消失。
|
||
*/
|
||
public ToolParam fallbackOptions(String[] values, String[] labels) {
|
||
this.fallbackValues = Arrays.asList(values);
|
||
this.fallbackLabels = Arrays.asList(labels);
|
||
return this;
|
||
}
|
||
|
||
public ToolParam dft(String value) {
|
||
this.defaultValue = value;
|
||
return this;
|
||
}
|
||
|
||
/** 字段中文标签 */
|
||
public ToolParam label(String value) {
|
||
this.label = value;
|
||
return this;
|
||
}
|
||
|
||
/** 示例值(帮助用户按格式填写) */
|
||
public ToolParam eg(String value) {
|
||
this.example = value;
|
||
return this;
|
||
}
|
||
|
||
/** 指定控件类型 */
|
||
public ToolParam as(String value) {
|
||
this.widget = value;
|
||
return this;
|
||
}
|
||
|
||
public ToolParam hide() {
|
||
this.hidden = true;
|
||
return this;
|
||
}
|
||
|
||
/** 下拉选项来自某个只读字典工具 */
|
||
public ToolParam optionsFrom(String tool) {
|
||
this.optionsFrom = tool;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 字典返回对象里,取哪个字段做「值」、哪个字段做「显示名」。
|
||
* <p>例:{@code optionsKeys("classId","className")} 对应 {@code {classId,className}} 这种业务 VO。
|
||
*/
|
||
public ToolParam optionsKeys(String valueKey, String labelKey) {
|
||
this.optionValueKey = valueKey;
|
||
this.optionLabelKey = labelKey;
|
||
return this;
|
||
}
|
||
|
||
/** 本字段的选项依赖的先序字段(用于联动下拉,如「学生」依赖「班级」) */
|
||
public ToolParam dependsOn(String... fieldNames) {
|
||
this.dependsOn = Arrays.asList(fieldNames);
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 数组值序列化分隔符:{@code POST_JSON} 时把多选的数组拼成模块要的「逗号串」。
|
||
* 例:{@code optional("fileIds","array",...).join(",")} → {@code "11,12,13"}。
|
||
*/
|
||
public ToolParam join(String separator) {
|
||
this.joinSeparator = separator;
|
||
return this;
|
||
}
|
||
|
||
/** 附件是否可多选(仅 {@code widget=file} 有意义) */
|
||
public ToolParam multiple(boolean value) {
|
||
this.multiple = value;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 「全部」哨兵值(如 {@code "ALL"}):表单会把它作为多选的第一个选项,
|
||
* 提交时与具体项互斥(见 {@link #allToken} 字段说明)。
|
||
*/
|
||
public ToolParam allOption(String token, String label) {
|
||
this.allToken = token;
|
||
this.allTokenLabel = label;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 声明为「推送范围」多选:提交时把选中的节点拼成
|
||
* {@code [{scopeType,scopeId,scopeName}]}(校园公告的 {@code pushScopes})。
|
||
*
|
||
* @param type 节点类型:1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员
|
||
*/
|
||
public ToolParam scopes(int type) {
|
||
this.widget = "scopes";
|
||
this.scopeType = type;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 声明为「审批流」控件(校园公告的 {@code flow})。
|
||
*
|
||
* <p>表单会渲染成:是否走审批 + 多步(每步可选或签/会签)+ 每步多选审批人
|
||
* (候选人来自 {@link #optionsFrom} 指向的教师树工具);提交时前端拼成
|
||
* {@code [{name:"第1步",vote:"any"|"all",approverTeaIds:[...]}]}。
|
||
*/
|
||
public ToolParam flow() {
|
||
this.widget = "flow";
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 声明本字段为**隐藏的镜像字段**:用户不填,提交时自动带上源字段选中项的显示名。
|
||
*
|
||
* <p>用于 {@code deptName} 这类「模块页会随包提交、但界面上没有对应输入框」的字段:
|
||
* 三个模块页都是「选部门 → 用字典把 id 反查成名称 → 一起提交」。
|
||
*/
|
||
public ToolParam mirrorLabelOf(String sourceField) {
|
||
this.mirrorLabelOf = sourceField;
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* 连续请假展开:本字段取 1 时,前端按 {@code countField} 的天数把
|
||
* {@code startField}/{@code endField} 复制成逐天时间段,生成 {@code continuousLeaveTime}。
|
||
*
|
||
* @param countField 连续天数字段名(如 continuousLeaveNum)
|
||
* @param startField 基准开始时间字段名(如 startDate)
|
||
* @param endField 基准结束时间字段名(如 endDate)
|
||
*/
|
||
public ToolParam expandDaily(String countField, String startField, String endField) {
|
||
this.dailyExpandSpec = new LinkedHashMap<>();
|
||
this.dailyExpandSpec.put("count", countField);
|
||
this.dailyExpandSpec.put("start", startField);
|
||
this.dailyExpandSpec.put("end", endField);
|
||
return this;
|
||
}
|
||
|
||
/** 附件控件提交**对象数组**(作业模块 {@code attachments:[{fileId,fileUrl,fileType,fileName,sort}]}) */
|
||
public ToolParam asAttachments() {
|
||
this.widget = "file";
|
||
this.fileObject = true;
|
||
return this;
|
||
}
|
||
|
||
/** 覆盖附件上传地址(缺省由编排层按 {@code /<模块>/api/base/FileUpload} 兜底) */
|
||
public ToolParam upload(String path) {
|
||
this.uploadPathOverride = path;
|
||
return this;
|
||
}
|
||
|
||
/** 表单里显示的名字 */
|
||
public String labelOf() {
|
||
return StringUtils.isNotBlank(label) ? label : name;
|
||
}
|
||
|
||
/** 当前生效的枚举值:权威枚举优先,其次内置兜底 */
|
||
private List<String> effectiveValues() {
|
||
if (enumValues != null && !enumValues.isEmpty()) return enumValues;
|
||
return fallbackValues;
|
||
}
|
||
|
||
/** 当前生效的枚举中文名,与 {@link #effectiveValues()} 对应 */
|
||
private List<String> effectiveLabels() {
|
||
if (enumValues != null && !enumValues.isEmpty()) return enumLabels;
|
||
return fallbackLabels;
|
||
}
|
||
|
||
/** 当前选项是否来自内置兜底(即实时字典没取到,用的是「大概是这样」的值) */
|
||
public boolean isUsingFallbackOptions() {
|
||
return (enumValues == null || enumValues.isEmpty())
|
||
&& fallbackValues != null && !fallbackValues.isEmpty();
|
||
}
|
||
|
||
/**
|
||
* 推断控件类型:显式指定优先,其次枚举→下拉,然后按类型与参数名语义判断。
|
||
* <p>参数名里的 date/time 是强信号(startDate / endDate / leaveTime),
|
||
* 文本类词根(remarks / reason / content)则渲染成多行输入。
|
||
*/
|
||
public String resolveWidget() {
|
||
if (StringUtils.isNotBlank(widget)) return widget;
|
||
List<String> vals = effectiveValues();
|
||
boolean isArray = "array".equalsIgnoreCase(StringUtils.trimToEmpty(type));
|
||
if (isArray) {
|
||
// 数组参数:有选项来源(枚举或字典)→ 多选下拉;否则退化成文本(用户手填逗号串)
|
||
if ((vals != null && !vals.isEmpty()) || StringUtils.isNotBlank(optionsFrom)) return "multiselect";
|
||
return "text";
|
||
}
|
||
if (vals != null && !vals.isEmpty()) return "select";
|
||
|
||
String t = StringUtils.trimToEmpty(type).toLowerCase();
|
||
if ("integer".equals(t) || "number".equals(t)) return "number";
|
||
if ("boolean".equals(t)) return "select";
|
||
|
||
String n = StringUtils.trimToEmpty(name).toLowerCase();
|
||
if (n.contains("date") || n.contains("time")) return "datetime";
|
||
if (n.contains("remark") || n.contains("reason") || n.contains("desc") || n.contains("memo")
|
||
|| n.contains("content") || n.contains("address") || n.contains("text")) {
|
||
return "textarea";
|
||
}
|
||
return "text";
|
||
}
|
||
|
||
/** 枚举 → 前端下拉项 [{value,label}](权威枚举优先,其次内置兜底) */
|
||
public List<Map<String, String>> optionList() {
|
||
List<Map<String, String>> list = new ArrayList<>();
|
||
List<String> vals = effectiveValues();
|
||
List<String> labs = effectiveLabels();
|
||
if (vals == null) return list;
|
||
for (int i = 0; i < vals.size(); i++) {
|
||
Map<String, String> o = new LinkedHashMap<>();
|
||
o.put("value", vals.get(i));
|
||
String lab = (labs != null && i < labs.size()) ? labs.get(i) : null;
|
||
o.put("label", StringUtils.isNotBlank(lab) ? lab : vals.get(i));
|
||
list.add(o);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/** 转成前端渲染用的表单字段 */
|
||
public FormField toFormField() {
|
||
FormField f = new FormField();
|
||
f.setName(name);
|
||
f.setLabel(labelOf());
|
||
f.setWidget(resolveWidget());
|
||
f.setType(type);
|
||
f.setRequired(required);
|
||
f.setDescription(description);
|
||
f.setExample(example);
|
||
f.setDefaultValue(defaultValue);
|
||
f.setOptions(optionList());
|
||
// 把「动态选项来源」一并下发:前端据此区分「本来就没有选项」和「字典没取到」
|
||
f.setOptionsFrom(optionsFrom);
|
||
f.setDependsOn(dependsOn);
|
||
f.setValueKey(optionValueKey);
|
||
f.setLabelKey(optionLabelKey);
|
||
f.setJoinSeparator(joinSeparator);
|
||
f.setMultiple(multiple == null || multiple);
|
||
f.setAllToken(allToken);
|
||
f.setAllTokenLabel(allTokenLabel);
|
||
f.setScopeType(scopeType);
|
||
f.setMirrorOf(mirrorLabelOf);
|
||
f.setDailyExpand(dailyExpandSpec);
|
||
f.setFileAsObject(fileObject);
|
||
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
||
f.setOptionsFallback(isUsingFallbackOptions());
|
||
return f;
|
||
}
|
||
|
||
/** 转成 JSON Schema 的属性节点(给模型看) */
|
||
public JSONObject toSchema() {
|
||
JSONObject o = new JSONObject();
|
||
o.put("type", type);
|
||
if ("array".equalsIgnoreCase(StringUtils.trimToEmpty(type))) {
|
||
JSONObject items = new JSONObject();
|
||
items.put("type", "string");
|
||
o.put("items", items);
|
||
}
|
||
String desc = description == null ? "" : description;
|
||
List<String> vals = effectiveValues();
|
||
if (vals != null && !vals.isEmpty()) {
|
||
desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "可选值:" + String.join(" / ", vals);
|
||
o.put("enum", new JSONArray() {{
|
||
addAll(vals);
|
||
}});
|
||
}
|
||
if (StringUtils.isNotBlank(example)) {
|
||
desc = desc + (StringUtils.isBlank(desc) ? "" : ";") + "示例:" + example;
|
||
}
|
||
o.put("description", desc);
|
||
return o;
|
||
}
|
||
}
|