Files
yunhai-agent-service/src/main/java/com/yunhai/agent/tool/ToolParam.java
T
lijing 322d589b3e
build / build-push (push) Successful in 54s
feat(form): 发布表单字段与各模块「新建发布」页完全对齐
原来助手表单只渲染"缺失的必填项",且控件只有文本/单选/时间 —— 与各模块
自己的发布页相比缺了接收对象多选、附件、定时发送、置顶、紧急程度、回执单据、
发布方式等字段,用户没法"填得一样"。

补齐一层共享的表单能力,再把三个发布工具按模块页逐字段重写:

共享能力
- ToolParam: 新增 join(分隔符)/multiple/allOption(哨兵)/scopes(推送范围),
  array 类型有选项来源时自动用多选控件;数组 schema 补 items
- ToolDef: 新增 fullForm() —— 表单渲染全部字段(不再只渲染缺失必填)
- ToolExecutor: POST_JSON 前按 joinSeparator 把数组拼成模块要的逗号串;
  allToken 与具体项互斥(只选具体项→拼串,否则发哨兵)
- AgentOrchestrator: file 控件补 uploadPath(/<模块>/api/base/FileUpload)
- 模板/参数文件同步下发 joinSeparator/uploadPath/multiple/allToken/scopeType

三个发布工具
- notice.publish: 补重要程度/接收班级(pushScopes 对象数组)/置顶/短信/阅读回执/
  附件/定时发送/发布方式;新增 notice.stuTree(组织树)
- dorm.publish: 补紧急程度/生效时间/回执截止/回执单据/接收宿舍(多选,ALL)/
  宿管配合/置顶/短信/附件/定时发送/存草稿
- activity.publish: 补报名截止/回执方式三档/名额上限/家长随行/活动费用/
  接收班级(多选,ALL)/短信/附件/定时发送/存草稿
- leave.submit/register: 连续请假改为可填参数,补连续天数与佐证材料附件

测试:发布工具契约 + 表单字段与模块页一致性断言;90 tests 全绿
2026-10-12 22:05:09 +08:00

387 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
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;
}
/** 表单里显示的名字 */
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.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;
}
}