feat(form): 发布表单字段与各模块「新建发布」页完全对齐
build / build-push (push) Successful in 54s

原来助手表单只渲染"缺失的必填项",且控件只有文本/单选/时间 —— 与各模块
自己的发布页相比缺了接收对象多选、附件、定时发送、置顶、紧急程度、回执单据、
发布方式等字段,用户没法"填得一样"。

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

共享能力
- 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 全绿
This commit is contained in:
lijing
2026-10-12 22:05:09 +08:00
parent b6a8a63b0e
commit 322d589b3e
13 changed files with 456 additions and 64 deletions
@@ -648,8 +648,25 @@ public class AgentOrchestrator {
List<FormField> fields = new ArrayList<>();
List<String> missingLabels = new ArrayList<>();
for (ToolParam p : missing) {
// ★ 表单包含哪些字段:发布类工具(fullForm)渲染**全部字段**,与模块自己的「新建/发布」页一致
// (置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项也摆出来,否则用户设不了、发出去的数据与模块页不同);
// 其余工具仍只补「缺失的必填项」。
List<ToolParam> 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<String, Object> preset = new LinkedHashMap<>();
@@ -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 有意义)。
* <p>前端展示时不需要它(用户看到的是多选),但下发给前端便于「提交前预览」如实呈现最终值。
*/
private String joinSeparator;
/**
* {@code widget=file} 时的上传地址(相对站点根),如 {@code /dormnotice/api/base/FileUpload}。
* <p>模块的附件上传只认浏览器 CAS 会话,必须由前端带用户登录态 POST(multipart),
* 拿到 {@code data.fileid} 后作为 fileIds 提交。
*/
private String uploadPath;
/** {@code widget=file} 时是否允许多个附件 */
private boolean multiple;
/**
* 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。
* <p>前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]};
* 同时勾了具体项 → 提交时折叠掉它,以具体项为准。
*/
private String allToken;
/** 「全部」选项的显示名 */
private String allTokenLabel;
/**
* {@code widget=scopes} 时的节点类型(校园公告 pushScopes):
* 1 全校 / 2 学部 / 3 年级 / 4 班级 / 5 训练队 / 6 指定学员。
* <p>前端把多选结果拼成 {@code [{scopeType,scopeId,scopeName}]}。
*/
private Integer scopeType;
private boolean required;
/** 说明(来自工具定义,写清格式与含义) */
@@ -34,9 +34,15 @@ public class FormRequest {
/** 提交成功后建议跳转的路由 */
private String landingPath;
/** 待补全的字段(只含缺失的必填项;已由用户说出的参数放在 preset 里回显) */
/** 待补全的字段(默认只含缺失的必填项;发布类工具为全部字段,见 {@link #allFields}) */
private List<FormField> fields = new ArrayList<>();
/**
* {@link #fields} 是否包含该工具的<b>全部字段</b>(含可选),即与业务模块的「新建/发布」页一致。
* <p>true 时前端不再单独渲染「已识别」只读标签(值已直接填进对应控件,避免同一信息出现两遍)。
*/
private boolean allFields;
/** 已经从对话里确定的参数(表单里以只读方式回显,用户可确认后提交;提交时原样带上) */
private Map<String, Object> preset = new LinkedHashMap<>();
@@ -89,6 +89,17 @@ public class ToolDef {
*/
private String landingPath;
/**
* 表单是否渲染**全部字段**(含可选字段),而不是只渲染"缺失的必填项"。
*
* <p>★ 用途:让助手里的补全表单与业务模块自己的「新建/发布」页**字段一致** ——
* 模块页会把置顶 / 短信 / 定时发送 / 附件 / 回执方式等可选项都摆出来,
* 若助手只渲染必填项,用户就没法设置这些字段,发出去的数据与在模块里发的天然不同。
*
* <p>默认 false(保持旧行为:只补必填,避免查询类工具的表单被可选过滤条件淹没)。
*/
private boolean formAll;
public ToolDef() {
}
@@ -147,6 +158,15 @@ public class ToolDef {
return this;
}
/**
* 让补全表单渲染**全部字段**(含可选),与业务模块的「新建/发布」页保持一致。
* <p>发布类工具都应调用它;查询类工具不要(否则表单会被一堆可选过滤条件淹没)。
*/
public ToolDef fullForm() {
this.formAll = true;
return this;
}
/** 当前调用者(角色集合)是否可用该工具。未声明角色 = 人人可用。 */
public boolean allowsAny(Set<AgentRole> callerRoles) {
if (roles == null || roles.isEmpty()) {
@@ -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<String, Object> normalizeJsonArgs(Map<String, Object> args, ToolDef def) {
Map<String, Object> out = new LinkedHashMap<>();
for (Map.Entry<String, Object> 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<String> items = toStringList(v);
String sep = p.getJoinSeparator();
// 「全部」哨兵(如 ALL):与具体项互斥 —— 同时选了以具体项为准;只选它或什么都没留则发哨兵
if (p.getAllToken() != null) {
List<String> 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<String> toStringList(Object v) {
List<String> 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<String, Object> args) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, Object> e : args.entrySet()) {
@@ -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;
* 缺省按参数名与类型自动推断。
*
* <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;
@@ -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<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();
@@ -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<String> vals = effectiveValues();
if (vals != null && !vals.isEmpty()) {
@@ -98,15 +98,19 @@ public final class ActivityTools {
private static void addPublishLoop(List<ToolDef> 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, "编辑家校活动",
@@ -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")
@@ -93,14 +93,19 @@ public final class DormNoticeTools {
private static void addPublishLoop(List<ToolDef> 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, "编辑宿舍通知",
@@ -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));
@@ -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<String> 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<String> 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<String> 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<String> paramNames(ToolDef def) {
List<String> names = new ArrayList<>();
for (ToolParam p : def.getParams()) {
names.add(p.getName());
}
return names;
}
private List<String> requiredNames(ToolDef def) {
List<String> names = new ArrayList<>();
for (ToolParam p : def.getParams()) {
@@ -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"));
@@ -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<String> 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"));