diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index e517b0a..e7855ab 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -648,8 +648,25 @@ public class AgentOrchestrator { List fields = new ArrayList<>(); List missingLabels = new ArrayList<>(); - for (ToolParam p : missing) { + // ★ 表单包含哪些字段:发布类工具(fullForm)渲染**全部字段**,与模块自己的「新建/发布」页一致 + // (置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项也摆出来,否则用户设不了、发出去的数据与模块页不同); + // 其余工具仍只补「缺失的必填项」。 + List shown = new ArrayList<>(); + if (def.isFormAll()) { + for (ToolParam p : def.getParams()) { + if (!p.isHidden()) { + shown.add(p); + } + } + } else { + shown.addAll(missing); + } + for (ToolParam p : shown) { FormField f = p.toFormField(); + // 附件控件:补上传地址(模块的附件上传只认浏览器 CAS 会话,必须前端带登录态 POST) + if ("file".equals(f.getWidget())) { + f.setUploadPath("/" + def.getModule() + "/api/base/FileUpload"); + } // 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。 // // ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话, @@ -660,7 +677,10 @@ public class AgentOrchestrator { BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller); if (optionCall != null) { f.setOptionCall(optionCall); - f.setWidget("select"); + // 多选字段保持 multiselect(接收班级/宿舍/审批人),其余走单选下拉 + if (!"multiselect".equals(f.getWidget())) { + f.setWidget("select"); + } // 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖 f.setOptionsFallback(p.isUsingFallbackOptions()); } else if (StringUtils.isNotBlank(p.getOptionsFrom())) { @@ -673,10 +693,13 @@ public class AgentOrchestrator { } } fields.add(f); + } + for (ToolParam p : missing) { missingLabels.add(p.labelOf()); } form.setFields(fields); form.setMissing(missingLabels); + form.setAllFields(def.isFormAll()); // 已从对话里确定的参数 → 表单里只读回显(值翻译成中文,用户能一眼核对) Map preset = new LinkedHashMap<>(); diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java index c322596..2c1209e 100644 --- a/src/main/java/com/yunhai/agent/tool/FormField.java +++ b/src/main/java/com/yunhai/agent/tool/FormField.java @@ -18,12 +18,45 @@ public class FormField { /** 中文标签,如「开始时间」 */ private String label; - /** 控件类型:text / textarea / number / date / datetime / select */ + /** 控件类型:text / textarea / number / date / datetime / select / multiselect / file */ private String widget; - /** 数据类型:string / integer / number / boolean */ + /** 数据类型:string / integer / number / boolean / array */ private String type; + /** + * 数组值的序列化分隔符(仅 POST_JSON 有意义)。 + *

前端展示时不需要它(用户看到的是多选),但下发给前端便于「提交前预览」如实呈现最终值。 + */ + private String joinSeparator; + + /** + * {@code widget=file} 时的上传地址(相对站点根),如 {@code /dormnotice/api/base/FileUpload}。 + *

模块的附件上传只认浏览器 CAS 会话,必须由前端带用户登录态 POST(multipart), + * 拿到 {@code data.fileid} 后作为 fileIds 提交。 + */ + private String uploadPath; + + /** {@code widget=file} 时是否允许多个附件 */ + private boolean multiple; + + /** + * 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。 + *

前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]}; + * 同时勾了具体项 → 提交时折叠掉它,以具体项为准。 + */ + private String allToken; + + /** 「全部」选项的显示名 */ + private String allTokenLabel; + + /** + * {@code widget=scopes} 时的节点类型(校园公告 pushScopes): + * 1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员。 + *

前端把多选结果拼成 {@code [{scopeType,scopeId,scopeName}]}。 + */ + private Integer scopeType; + private boolean required; /** 说明(来自工具定义,写清格式与含义) */ diff --git a/src/main/java/com/yunhai/agent/tool/FormRequest.java b/src/main/java/com/yunhai/agent/tool/FormRequest.java index 96b3e5e..87571be 100644 --- a/src/main/java/com/yunhai/agent/tool/FormRequest.java +++ b/src/main/java/com/yunhai/agent/tool/FormRequest.java @@ -34,9 +34,15 @@ public class FormRequest { /** 提交成功后建议跳转的路由 */ private String landingPath; - /** 待补全的字段(只含缺失的必填项;已由用户说出的参数放在 preset 里回显) */ + /** 待补全的字段(默认只含缺失的必填项;发布类工具为全部字段,见 {@link #allFields}) */ private List fields = new ArrayList<>(); + /** + * {@link #fields} 是否包含该工具的全部字段(含可选),即与业务模块的「新建/发布」页一致。 + *

true 时前端不再单独渲染「已识别」只读标签(值已直接填进对应控件,避免同一信息出现两遍)。 + */ + private boolean allFields; + /** 已经从对话里确定的参数(表单里以只读方式回显,用户可确认后提交;提交时原样带上) */ private Map preset = new LinkedHashMap<>(); diff --git a/src/main/java/com/yunhai/agent/tool/ToolDef.java b/src/main/java/com/yunhai/agent/tool/ToolDef.java index efb3cb6..86a6359 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolDef.java +++ b/src/main/java/com/yunhai/agent/tool/ToolDef.java @@ -89,6 +89,17 @@ public class ToolDef { */ private String landingPath; + /** + * 表单是否渲染**全部字段**(含可选字段),而不是只渲染"缺失的必填项"。 + * + *

★ 用途:让助手里的补全表单与业务模块自己的「新建/发布」页**字段一致** —— + * 模块页会把置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项都摆出来, + * 若助手只渲染必填项,用户就没法设置这些字段,发出去的数据与在模块里发的天然不同。 + * + *

默认 false(保持旧行为:只补必填,避免查询类工具的表单被可选过滤条件淹没)。 + */ + private boolean formAll; + public ToolDef() { } @@ -147,6 +158,15 @@ public class ToolDef { return this; } + /** + * 让补全表单渲染**全部字段**(含可选),与业务模块的「新建/发布」页保持一致。 + *

发布类工具都应调用它;查询类工具不要(否则表单会被一堆可选过滤条件淹没)。 + */ + public ToolDef fullForm() { + this.formAll = true; + return this; + } + /** 当前调用者(角色集合)是否可用该工具。未声明角色 = 人人可用。 */ public boolean allowsAny(Set callerRoles) { if (roles == null || roles.isEmpty()) { diff --git a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java index d89b243..a947c2e 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolExecutor.java +++ b/src/main/java/com/yunhai/agent/tool/ToolExecutor.java @@ -18,6 +18,7 @@ import org.springframework.web.util.UriComponentsBuilder; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -447,7 +448,7 @@ public class ToolExecutor { return builder.build(false).toUriString(); } - /** POST_JSON:只保留非空参数 */ + /** POST_JSON:只保留非空参数;数组参数按声明做「数组 → 分隔符串」序列化 */ private Map normalizeJsonArgs(Map args, ToolDef def) { Map out = new LinkedHashMap<>(); for (Map.Entry e : args.entrySet()) { @@ -455,11 +456,72 @@ public class ToolExecutor { if (v == null || StringUtils.isBlank(String.valueOf(v))) { continue; } + // ★ 模块多数字段在 wire 上是逗号串(receiveList / fileIds),而表单里用户是多选的(数组)。 + // 参数声明 .join(",") 时在此拼串,避免"页面选得到、后端判非法"。 + ToolParam p = findParam(def, e.getKey()); + if (p != null && p.getJoinSeparator() != null && (v instanceof List || v instanceof JSONArray)) { + List items = toStringList(v); + String sep = p.getJoinSeparator(); + // 「全部」哨兵(如 ALL):与具体项互斥 —— 同时选了以具体项为准;只选它或什么都没留则发哨兵 + if (p.getAllToken() != null) { + List specific = new ArrayList<>(); + for (String it : items) { + if (!p.getAllToken().equals(it)) { + specific.add(it); + } + } + if (!specific.isEmpty()) { + out.put(e.getKey(), String.join(sep, specific)); + } else { + out.put(e.getKey(), p.getAllToken()); + } + continue; + } + String joined = String.join(sep, items); + if (StringUtils.isBlank(joined)) { + continue; + } + out.put(e.getKey(), joined); + continue; + } out.put(e.getKey(), v); } return out; } + /** 把数组/集合统一成字符串列表(供 join 与「全部」哨兵判断用) */ + private List toStringList(Object v) { + List out = new ArrayList<>(); + if (v instanceof List) { + for (Object o : (List) v) { + if (o != null) { + out.add(String.valueOf(o)); + } + } + } else if (v instanceof JSONArray) { + for (Object o : (JSONArray) v) { + if (o != null) { + out.add(String.valueOf(o)); + } + } + } else if (v != null) { + out.add(String.valueOf(v)); + } + return out; + } + + private ToolParam findParam(ToolDef def, String name) { + if (def == null || def.getParams() == null) { + return null; + } + for (ToolParam p : def.getParams()) { + if (name != null && name.equals(p.getName())) { + return p; + } + } + return null; + } + private String toFormBody(Map args) { StringBuilder sb = new StringBuilder(); for (Map.Entry e : args.entrySet()) { diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java index 13e5f5c..a0f6db0 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolParam.java +++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java @@ -62,9 +62,52 @@ public class ToolParam { /** 示例值(表单 placeholder,如 2026-10-11 08:00:00),也会进入给模型的描述 */ private String example; - /** 控件类型:text / textarea / number / date / datetime / select;缺省按参数名与类型自动推断 */ + /** + * 控件类型:text / textarea / number / date / datetime / select / multiselect / file; + * 缺省按参数名与类型自动推断。 + * + *

    + *
  • {@code multiselect}:多选下拉(配合 {@code type=array}),用于「接收班级 / 宿舍 / 审批人」这类 + * 模块里是多选的字段。
  • + *
  • {@code file}:附件上传(对齐模块页的「添加附件」),上传后得到 fileid 列表。
  • + *
+ */ private String widget; + /** + * 数组值的序列化分隔符(仅 {@code POST_JSON} 生效)。 + * + *

★ 为什么需要:模块的很多字段在 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; + + /** + * 「全部」哨兵值。 + * + *

★ 用途:模块的接收对象多选里都有一个「全部班级 / 全部宿舍」开关,wire 上是 {@code "ALL"}。 + * 声明后:① 表单把它作为第一个选项摆进多选;② 提交时若用户既没选具体项、又没选它, + * 视作「全部」(保持"默认发全体"的语义);③ 若同时选了具体项与它,以**具体项**为准(折叠掉它)。 + */ + private String allToken; + + /** 「全部」选项在表单里显示的中文名,如「全部班级」/「全部宿舍」 */ + private String allTokenLabel; + + /** + * 「推送范围」控件(widget=scopes)的节点类型。 + * + *

校园公告的接收对象 {@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; @@ -185,6 +228,43 @@ public class ToolParam { 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; @@ -216,6 +296,12 @@ public class ToolParam { public String resolveWidget() { if (StringUtils.isNotBlank(widget)) return widget; List 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(); @@ -264,6 +350,11 @@ public class ToolParam { 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; @@ -273,6 +364,11 @@ public class ToolParam { 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 vals = effectiveValues(); if (vals != null && !vals.isEmpty()) { diff --git a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java index 63352c7..ac1d9c7 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java @@ -98,15 +98,19 @@ public final class ActivityTools { private static void addPublishLoop(List list) { list.add(ToolDef.of("activity.publish", MODULE, "发布家校活动", - "发布一条家校活动(可要求家长报名),默认发给**全部班级**的家长。" + "发布一条家校活动(可要求家长报名)。字段与家校活动模块的「新建活动」页**完全一致**:" + + "标题、活动分类、主办部门、活动地点、活动时间、报名截止、回执方式、名额上限、" + + "家长随行、活动费用、接收班级、短信提醒、附件、正文、定时发送、存草稿。" + "调用前必须确认:标题 title、正文 content、活动分类 category、主办部门 deptId、" + "活动地点 venue、开始/结束时间 startTime/endTime;" - + "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;" - + "活动分类与主办部门必须来自字典,不要编造。默认立即发布;如需先存草稿,把 draft 置 true。", + + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + + "活动分类与主办部门必须来自字典,不要编造。" + + "接收班级 receiveList 默认「全部班级」;要定向到具体班级时由表单多选。" + + "timingSendTime 非空 = 定时发布;draft=true = 只存草稿。", "POST_JSON", "/api/activity/Create") - .p(ToolParam.required("title", "string", "活动标题") + .p(ToolParam.required("title", "string", "活动标题(50 字以内)") .label("活动标题").eg("秋季亲子运动会"), - ToolParam.required("content", "string", "活动说明(纯文本)") + ToolParam.required("content", "string", "活动说明(纯文本,对应模块页的富文本编辑器)") .label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"), ToolParam.required("category", "string", "活动分类,来自 activity.categoryDic 的 k 字段") .label("活动分类") @@ -120,11 +124,13 @@ public final class ActivityTools { .label("开始时间").eg("2026-10-20 09:00:00"), ToolParam.required("endTime", "string", "活动结束时间 yyyy-MM-dd HH:mm:ss") .label("结束时间").eg("2026-10-20 11:30:00"), - ToolParam.required("receiptMode", "string", "报名方式:signup 需要家长报名 / none 无需报名") - .label("报名方式").dft("signup") - .options(new String[]{"signup", "none"}, new String[]{"需要家长报名", "无需报名"}), ToolParam.optional("signupDeadline", "string", "报名截止时间 yyyy-MM-dd HH:mm:ss(留空=不限)") - .label("报名截止时间").eg("2026-10-18 18:00:00"), + .label("报名截止").eg("2026-10-18 18:00:00"), + ToolParam.required("receiptMode", "string", + "回执方式:none 仅通知 / signup 需报名 / confirm 需确认(打开即自动确认)") + .label("回执方式").dft("signup") + .options(new String[]{"none", "signup", "confirm"}, + new String[]{"仅通知", "需报名", "需确认"}), ToolParam.optional("quota", "integer", "名额上限(0 或留空 = 不限)") .label("名额上限").dft("0"), ToolParam.optional("allowAccompany", "integer", "是否允许家长随行") @@ -132,18 +138,23 @@ public final class ActivityTools { .options(new String[]{"1", "0"}, new String[]{"允许", "不允许"}), ToolParam.optional("cost", "number", "活动费用(元,留空=免费)") .label("活动费用"), + ToolParam.optional("receiveList", "array", "接收班级:班级 id 列表;留空或含 ALL = 全部班级") + .label("接收班级") + .optionsFrom("activity.classTree").optionsKeys("id", "name") + .join(",").allOption("ALL", "全部班级"), ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒家长") - .label("同时发短信").dft("0") + .label("短信提醒").dft("0") .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("topmost", "integer", "是否置顶") - .label("置顶").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("draft", "string", "是否存草稿:true 只保存不发布") + ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") + .label("添加附件").as("file").multiple(true).join(","), + ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)") + .label("定时发送").eg("2026-10-19 08:00:00"), + ToolParam.optional("draft", "boolean", "是否只存草稿(true 不发布)") .label("存草稿").dft("false") .options(new String[]{"false", "true"}, new String[]{"立即发布", "存草稿"})) - // 接收对象:默认全部班级(班级级定向请到模块页面里勾选) + // 接收对象类型固定为「班级」级(与模块页一致) .fix("receiveType", "class") - .fix("receiveList", "ALL") + .fullForm() .write("/list/new")); list.add(ToolDef.of("activity.edit", MODULE, "编辑家校活动", diff --git a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java index 60463f4..7e9c37e 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java @@ -62,30 +62,58 @@ public final class CampusNoticeTools { .readAt("/list/new")); list.add(ToolDef.of("notice.publish", MODULE, "发布校园公告", - "发布一条校园公告,**立即生效、全站可见**(是否转审批由学校「应用设置」里的流程绑定决定," - + "请求侧无法选择)。调用前必须确认:标题 title、正文 content、发文部门 deptId;" - + "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;" - + "发文部门必须来自字典,不要编造。", + "发布一条校园公告。字段与校园公告模块的「新建公告」页**完全一致**:" + + "标题、重要程度、来源部门、接收班级、置顶、短信提醒、学员阅读回执、附件、正文、定时发送、发布方式。" + + "调用前必须确认:标题 title、正文 content、发文部门 deptId;" + + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + + "发文部门必须来自字典,不要编造。" + + "接收班级 pushScopes 默认「全部班级」;要定向到具体班级时由表单多选。" + + "发布方式 publishType:direct 直接发布(默认)/ draft 存为草稿。" + + "timingSendTime 非空 = 定时发送。", "POST_JSON", "/api/notice/Create") - .p(ToolParam.required("title", "string", "公告标题") + .p(ToolParam.required("title", "string", "公告标题(200 字以内)") .label("公告标题").eg("关于国庆假期安排的通知"), - ToolParam.required("content", "string", "公告正文(纯文本)") + ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)") .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……"), - ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段") - .label("发文部门") - .optionsFrom("notice.deptDics").optionsKeys("k", "v"), ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要") .label("重要程度").dft("1") .options(new String[]{"1", "2"}, new String[]{"普通", "重要"}), - ToolParam.optional("receiptFlag", "integer", "是否需要阅读回执(要回执时收件人需点确认)") - .label("需要阅读回执").dft("0") + ToolParam.required("deptId", "string", "发文部门 id,来自 notice.deptDics 的 k 字段") + .label("发文部门") + .optionsFrom("notice.deptDics").optionsKeys("k", "v"), + ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级") + .label("接收班级") + .optionsFrom("notice.stuTree").optionsKeys("id", "name") + .scopes(4).allOption("ALL", "全部班级"), + ToolParam.optional("topmost", "integer", "是否置顶") + .label("置顶").dft("0") .options(new String[]{"0", "1"}, new String[]{"否", "是"}), ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒") - .label("同时发短信").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"})) + .label("短信提醒").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("receiptFlag", "integer", "是否需要学员阅读回执(要回执时收件人需点确认)") + .label("学员阅读回执").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") + .label("添加附件").as("file").multiple(true).join(","), + ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)") + .label("定时发送").eg("2026-10-13 08:00:00"), + ToolParam.optional("publishType", "string", "发布方式:direct 直接发布 / draft 存为草稿") + .label("发布方式").dft("direct") + .options(new String[]{"direct", "draft"}, new String[]{"直接发布", "存为草稿"})) + .fullForm() .write("/list/new") .modId(null)); + list.add(ToolDef.of("notice.stuTree", MODULE, "组织树(学员)", + "查询本校组织树(学部 / 年级 / 班级),用于发布公告时选择接收班级。" + + "仅在用户明确要「按组织范围选人」时使用;" + + "发布公告时**不要**先调它 —— 表单的「接收班级」多选会自动代取该树。", + "GET", "/api/base/getStuTree") + .fix("toUser", false) + .readAt("/list/new") + .modId(null)); + list.add(ToolDef.of("notice.approvalList", MODULE, "待我审批的公告", "查询待我审批的公告列表(公告审批中心)。", "GET", "/api/notice/ApprovalList") diff --git a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java index 093b269..b45ecae 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java @@ -93,14 +93,19 @@ public final class DormNoticeTools { private static void addPublishLoop(List list) { list.add(ToolDef.of("dorm.publish", MODULE, "发布宿舍通知", - "发布一条宿舍通知(可要求家长回执 / 填报),默认发给**全部宿舍**的家长。" + "发布一条宿舍通知(可要求家长回执 / 填报)。" + + "字段与宿舍通知模块的「新建通知」页**完全一致**:" + + "标题、通知类型、发布部门、紧急程度、生效时间、回执截止、回执方式、回执单据、" + + "接收宿舍、宿管配合、置顶、短信、附件、正文、定时发送、存草稿。" + "调用前必须确认:标题 title、正文 content、通知类型 noticeType、发文部门 deptId;" - + "缺失就直接调用本工具,系统会弹出表单让用户补齐,不要用文字反复追问;" - + "通知类型与发文部门必须来自字典,不要编造。默认立即发布;如需先存草稿,把 draft 置 true。", + + "缺失就直接调用本工具,系统会弹出**完整表单**让用户填写,不要用文字反复追问;" + + "通知类型与发文部门必须来自字典,不要编造。" + + "接收宿舍 receiveList 默认「全部宿舍」;要发到指定房间时由表单多选(逗号串或 ALL)。" + + "timingSendTime 非空 = 定时发布;draft=true = 只存草稿。", "POST_JSON", "/api/notice/Create") - .p(ToolParam.required("title", "string", "通知标题") + .p(ToolParam.required("title", "string", "通知标题(50 字以内)") .label("通知标题").eg("关于宿舍熄灯时间调整的通知"), - ToolParam.required("content", "string", "通知正文(纯文本)") + ToolParam.required("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)") .label("通知正文").as("textarea").eg("各位家长:自本周起,宿舍熄灯时间调整为 22:30……"), ToolParam.required("noticeType", "string", "通知类型,来自 dorm.noticeTypeDic 的 k 字段") .label("通知类型") @@ -108,27 +113,44 @@ public final class DormNoticeTools { ToolParam.required("deptId", "string", "发文部门 id,来自 dorm.deptDic 的 k 字段") .label("发文部门") .optionsFrom("dorm.deptDic").optionsKeys("k", "v"), - ToolParam.required("receiptMode", "string", "回执方式:none 无需回执 / confirm 确认回执 / fill 填报回执") + ToolParam.optional("level", "string", "紧急程度:normal 普通 / important 重要 / urgent 紧急") + .label("紧急程度").dft("normal") + .options(new String[]{"normal", "important", "urgent"}, + new String[]{"普通", "重要", "紧急"}), + ToolParam.optional("effectiveTime", "string", "生效时间 yyyy-MM-dd HH:mm:ss(留空=立即生效)") + .label("生效时间").eg("2026-10-13 06:00:00"), + ToolParam.optional("deadlineTime", "string", "回执截止时间 yyyy-MM-dd HH:mm:ss(留空=不设截止)") + .label("回执截止时间").eg("2026-10-20 18:00:00"), + ToolParam.required("receiptMode", "string", "回执方式:none 仅通知 / confirm 需确认 / fill 需填报") .label("回执方式").dft("none") .options(new String[]{"none", "confirm", "fill"}, - new String[]{"无需回执", "需要确认回执", "需要填报回执"}), - ToolParam.optional("deadlineTime", "string", "回执截止时间 yyyy-MM-dd HH:mm:ss(留空=不限)") - .label("回执截止时间").eg("2026-10-20 18:00:00"), - ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行") + new String[]{"仅通知", "需确认", "需填报"}), + ToolParam.optional("formType", "string", "回执单据类型(receiptMode=fill 时必填),来自 dorm.formTemplates 的 type 字段") + .label("回执单据") + .optionsFrom("dorm.formTemplates").optionsKeys("type", "name"), + ToolParam.optional("receiveList", "array", "接收宿舍:房间 id 列表;留空或含 ALL = 全部宿舍") + .label("接收宿舍") + .optionsFrom("dorm.rooms").optionsKeys("roomId", "buildingName+roomNo") + .join(",").allOption("ALL", "全部宿舍"), + ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅需回执时有意义)") .label("宿管到场配合").dft("0") .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("topmost", "integer", "是否置顶展示") + .label("置顶展示").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), ToolParam.optional("smsFlag", "integer", "是否同时发短信提醒家长") - .label("同时发短信").dft("0") + .label("短信提醒").dft("0") .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("topmost", "integer", "是否置顶") - .label("置顶").dft("0") - .options(new String[]{"0", "1"}, new String[]{"否", "是"}), - ToolParam.optional("draft", "string", "是否存草稿:true 只保存不发布") + ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)") + .label("添加附件").as("file").multiple(true).join(","), + ToolParam.optional("timingSendTime", "string", "定时发送时间 yyyy-MM-dd HH:mm:ss(留空=立即发布)") + .label("定时发送").eg("2026-10-13 08:00:00"), + ToolParam.optional("draft", "boolean", "是否只存草稿(true 不发布)") .label("存草稿").dft("false") .options(new String[]{"false", "true"}, new String[]{"立即发布", "存草稿"})) - // 接收对象:默认全部宿舍(房间级定向请到模块页面里勾选) + // 接收对象类型固定为「房间」级(与模块页一致) .fix("receiveType", "room") - .fix("receiveList", "ALL") + .fullForm() .write("/list/new")); list.add(ToolDef.of("dorm.editNotice", MODULE, "编辑宿舍通知", 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 33f2941..ca793b7 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/LeaveTools.java @@ -127,9 +127,16 @@ public final class LeaveTools { .label("请假事由").as("textarea").eg("发烧就医,需在家休息"), ToolParam.optional("classId", "integer", "班级 id;只有一个班级时可不传(后端会取默认班级)") .label("班级") - .optionsFrom("leave.myClasses").optionsKeys("classId", "className")) + .optionsFrom("leave.myClasses").optionsKeys("classId", "className"), + ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") + .label("连续请假").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)") + .label("连续天数").dft("1"), + ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到)") + .label("佐证材料").as("file").multiple(false).join(",")) .fix("type", 2) - .fix("continuousLeave", 0) + .fullForm() .write("/my") .modId(2)); @@ -156,9 +163,16 @@ public final class LeaveTools { ToolParam.required("endDate", "string", "请假结束时间," + DT_HINT) .label("结束时间").eg("2026-10-12 17:00:00"), ToolParam.required("remarks", "string", "请假事由") - .label("请假事由").as("textarea").eg("家中有事,需请假一天")) + .label("请假事由").as("textarea").eg("家中有事,需请假一天"), + ToolParam.optional("continuousLeave", "integer", "是否连续请假(同一时段连续多天)") + .label("连续请假").dft("0") + .options(new String[]{"0", "1"}, new String[]{"否", "是"}), + ToolParam.optional("continuousLeaveNum", "integer", "连续天数(continuousLeave=1 时有效,1~30)") + .label("连续天数").dft("1"), + ToolParam.optional("fileIds", "array", "佐证材料附件 id 列表(由表单的附件上传得到)") + .label("佐证材料").as("file").multiple(false).join(",")) .fix("type", 1) - .fix("continuousLeave", 0) + .fullForm() .write("/register") .modId(5)); diff --git a/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java b/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java index 7f8ce56..8815fff 100644 --- a/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java +++ b/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java @@ -121,9 +121,14 @@ class DormActivityPublishIntentTest { assertTrue(required.contains("noticeType"), "通知类型是发布表单必填项"); assertTrue(required.contains("deptId"), "发文部门是发布表单必填项"); - // 接收对象由 fix 写死(避免表单里出现房间 id 输入框) + // 接收对象:receiveType 固定为「房间」级;receiveList 由表单多选(默认「全部宿舍」) assertEquals("room", publish.getFixed().get("receiveType")); - assertEquals("ALL", publish.getFixed().get("receiveList")); + ToolParam receiveList = param(publish, "receiveList"); + assertNotNull(receiveList, "接收宿舍应作为可选字段出现在表单里(不再写死)"); + assertEquals("ALL", receiveList.getAllToken(), "「全部宿舍」哨兵是 ALL"); + assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串"); + assertEquals("dorm.rooms", receiveList.getOptionsFrom()); + assertTrue(publish.isFormAll(), "发布表单应渲染全部字段(与模块「新建通知」页一致)"); } @Test @@ -166,8 +171,13 @@ class DormActivityPublishIntentTest { assertTrue(required.contains(k), "发布活动表单缺少必填项:" + k); } + // 接收对象:receiveType 固定为「班级」级;receiveList 由表单多选(默认「全部班级」) assertEquals("class", publish.getFixed().get("receiveType")); - assertEquals("ALL", publish.getFixed().get("receiveList")); + ToolParam receiveList = param(publish, "receiveList"); + assertNotNull(receiveList); + assertEquals("ALL", receiveList.getAllToken()); + assertEquals("activity.classTree", receiveList.getOptionsFrom()); + assertTrue(publish.isFormAll(), "发布表单应渲染全部字段(与模块「新建活动」页一致)"); } @Test @@ -185,8 +195,65 @@ class DormActivityPublishIntentTest { "活动分类字典是 type=2"); } + /* ==================== 发布表单字段对齐(与模块「新建/发布」页一致) ==================== */ + + @Test + void dormPublishFormMatchesModulePage() { + List names = paramNames(registry.get("dorm.publish")); + for (String k : Arrays.asList("title", "content", "noticeType", "deptId", "level", "effectiveTime", + "deadlineTime", "receiptMode", "formType", "receiveList", "needSupervisor", "topmost", + "smsFlag", "fileIds", "timingSendTime", "draft")) { + assertTrue(names.contains(k), "宿舍发布表单缺少字段(模块页有):" + k); + } + } + + @Test + void activityPublishFormMatchesModulePage() { + List names = paramNames(registry.get("activity.publish")); + for (String k : Arrays.asList("title", "content", "category", "deptId", "venue", "startTime", "endTime", + "signupDeadline", "receiptMode", "quota", "allowAccompany", "cost", "receiveList", + "smsFlag", "fileIds", "timingSendTime", "draft")) { + assertTrue(names.contains(k), "活动发布表单缺少字段(模块页有):" + k); + } + // 回执方式必须是模块页的三档(none / signup / confirm) + ToolParam mode = param(registry.get("activity.publish"), "receiptMode"); + assertTrue(mode.getEnumValues().containsAll(Arrays.asList("none", "signup", "confirm")), + "活动回执方式缺少 confirm 档"); + } + + @Test + void noticePublishFormMatchesModulePage() { + List names = paramNames(registry.get("notice.publish")); + for (String k : Arrays.asList("title", "content", "type", "deptId", "pushScopes", "topmost", + "smsFlag", "receiptFlag", "fileIds", "timingSendTime", "publishType")) { + assertTrue(names.contains(k), "公告发布表单缺少字段(模块页有):" + k); + } + // 接收班级是「推送范围」控件({scopeType,scopeId,scopeName}),默认含「全部」 + ToolParam scopes = param(registry.get("notice.publish"), "pushScopes"); + assertEquals("scopes", scopes.resolveWidget()); + assertEquals(Integer.valueOf(4), scopes.getScopeType(), "班级节点 scopeType=4"); + assertEquals("ALL", scopes.getAllToken()); + } + + @Test + void attachmentFieldsAreFileWidgetsWithJoin() { + for (String tool : Arrays.asList("notice.publish", "dorm.publish", "activity.publish")) { + ToolParam f = param(registry.get(tool), "fileIds"); + assertEquals("file", f.resolveWidget(), tool + " 的附件应是上传控件"); + assertEquals(",", f.getJoinSeparator(), tool + " 的 fileIds 在 wire 上是逗号串"); + } + } + /* ==================== 辅助 ==================== */ + private List paramNames(ToolDef def) { + List names = new ArrayList<>(); + for (ToolParam p : def.getParams()) { + names.add(p.getName()); + } + return names; + } + private List requiredNames(ToolDef def) { List names = new ArrayList<>(); for (ToolParam p : def.getParams()) { diff --git a/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java b/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java index 34946bb..6f04eb1 100644 --- a/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java +++ b/src/test/java/com/yunhai/agent/LeaveIntentFormTest.java @@ -95,7 +95,9 @@ class LeaveIntentFormTest { assertFalse(submit.isReadOnly(), "请假申请是写操作"); assertEquals(2, ((Number) submit.getFixed().get("type")).intValue(), "学生申请必须固定 type=2(待审核)"); - assertEquals(0, ((Number) submit.getFixed().get("continuousLeave")).intValue()); + // 「连续请假」此前是写死的 fix(0),现改为可填参数(默认否),以便与模块发布页字段一致 + ToolParam continuousLeave = param(submit, "continuousLeave"); + assertEquals("0", continuousLeave.getDefaultValue(), "「连续请假」默认「否」(0)"); // 学生表单的必填项:类型 / 起止时间 / 事由 assertTrue(requiredNames(submit).contains("leaveType")); diff --git a/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java b/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java index 9d26ee4..9956b37 100644 --- a/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java +++ b/src/test/java/com/yunhai/agent/NoticeIntentFormTest.java @@ -13,7 +13,9 @@ import org.springframework.web.client.RestTemplate; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -111,15 +113,21 @@ class NoticeIntentFormTest { /* ==================== 发布工具的真实契约 ==================== */ @Test - void publishToolPublishesDirectlyWithoutPhantomPublishType() { + void publishToolDeclaresModulePageFields() { ToolDef publish = registry.get("notice.publish"); assertNotNull(publish); assertFalse(publish.isReadOnly(), "发布公告是写操作"); assertEquals("/api/notice/Create", publish.getPath()); + // 2026-10-12:以「校园公告模块的新建页(CampusAnnouncement/NewNotice.vue)」为准, + // 该页**确实**提交 publishType(direct / draft)与 pushScopes / topmost / fileIds / timingSendTime。 + // (早先按旧 noticeservice 源码判断"没有 publishType",已按新前端契约更正。) + Set declared = new HashSet<>(); for (ToolParam p : publish.getParams()) { - assertFalse("publishType".equals(p.getName()), - "后端 /api/notice/Create 不支持发布方式,声明该参数会造成「选了存草稿却直接发出」"); + declared.add(p.getName()); + } + for (String k : Arrays.asList("publishType", "pushScopes", "topmost", "fileIds", "timingSendTime")) { + assertTrue(declared.contains(k), "公告发布表单缺少字段(模块页有):" + k); } assertTrue(requiredNames(publish).contains("title")); assertTrue(requiredNames(publish).contains("content"));