diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java
index fb5ab37..c0a2fd2 100644
--- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java
+++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java
@@ -692,15 +692,32 @@ public class AgentOrchestrator {
* @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload}
*/
public static String resolveUploadPath(String module, String moduleBaseUrl, String override) {
+ return resolveModulePath(module, moduleBaseUrl, override, "/api/base/FileUpload");
+ }
+
+ /**
+ * 把「模块内相对路径」拼成「相对站点根」的路径({@link #resolveUploadPath} 的通用形态)。
+ *
+ *
附件上传、正文图片、选人弹窗的数据源都走这一条 —— 它们的共同点是
+ * 「地址只写模块内相对部分,前缀由编排层拼」。早先 {@code profession.publish} 把
+ * {@code /api/file/Upload} 当完整路径直接下发,线上打到站点根 ⇒ 404(老坑,别再来一次)。
+ *
+ * @param relFallback {@code rel} 为空时的默认相对路径
+ */
+ public static String resolveModulePath(String module, String moduleBaseUrl, String rel, String relFallback) {
String prefix = pathPrefix(module, moduleBaseUrl);
- String rel = StringUtils.isNotBlank(override) ? override.trim() : "/api/base/FileUpload";
- if (!rel.startsWith("/")) {
- rel = "/" + rel;
+ String path = StringUtils.isNotBlank(rel) ? rel.trim() : relFallback;
+ if (StringUtils.isBlank(path)) {
+ return "";
}
- if (rel.equals(prefix) || rel.startsWith(prefix + "/")) {
- return rel;
+ if (!path.startsWith("/")) {
+ path = "/" + path;
}
- return prefix + rel;
+ // 已经带了前缀的写法保持幂等(/notice/api/... 不会再被拼成 /notice/notice/api/...)
+ if (path.equals(prefix) || path.startsWith(prefix + "/")) {
+ return path;
+ }
+ return prefix + path;
}
/** 由工具定义生成补全表单 */
@@ -740,6 +757,38 @@ public class AgentOrchestrator {
f.setUploadPath(resolveUploadPath(def.getModule(),
settings.getModules().get(def.getModule()), p.getUploadPathOverride()));
}
+ // 富文本正文:正文里「插入图片」用的是**另一个**接口(UploadImage / Preview),
+ // 与附件的 FileUpload 不是一回事,所以单独下发两条(同样只写相对路径,前缀在这里拼)。
+ if ("richtext".equals(f.getWidget())) {
+ String baseUrl = settings.getModules().get(def.getModule());
+ f.setRichTextUploadPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextUploadOverride()));
+ f.setRichTextPreviewPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextPreviewOverride()));
+ }
+ // 「通知对象」自绘控件(userpicker)的选人弹窗数据源:模块页有两个页签
+ // (基础平台组织树 / 自定义组),optionCall 只能描述一个地址,其余由这里补齐。
+ // 同样只写相对路径,前缀在这里拼(见 resolveModulePath)。
+ if ("userpicker".equals(f.getWidget()) && p.getPickerSources() != null) {
+ String baseUrl = settings.getModules().get(def.getModule());
+ List srcs = new ArrayList<>();
+ for (String[] ps : p.getPickerSources()) {
+ if (ps == null || ps.length < 2 || StringUtils.isBlank(ps[1])) {
+ continue;
+ }
+ BrowserToolRequest.BrowserCall call = new BrowserToolRequest.BrowserCall();
+ call.setName("picker." + ps[0]);
+ call.setTitle("选择用户 · " + ps[0]);
+ call.setWrite(false);
+ call.setMethod("GET");
+ call.setPathAndQuery(resolveModulePath(def.getModule(), baseUrl, ps[1], null));
+ FormField.PickerSource src = new FormField.PickerSource();
+ src.setName(ps[0]);
+ src.setCall(call);
+ srcs.add(src);
+ }
+ if (!srcs.isEmpty()) {
+ f.setPickerSources(srcs);
+ }
+ }
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
//
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
@@ -750,10 +799,14 @@ public class AgentOrchestrator {
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
if (optionCall != null) {
f.setOptionCall(optionCall);
- // ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、
- // flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。
+ // ★ 多值/自绘控件必须保留原样:
+ // multiselect(接收班级/宿舍)、scopes(公告推送范围)、flow(公告审批流)
+ // 都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力;
+ // userpicker(通知公告的「通知对象」)是**自绘控件**(radio 全部/指定 + 选人弹窗),
+ // 它自己会用 optionCall 去取组织树 —— 降级成 select 就等于把它改回普通下拉。
String w = f.getWidget();
- if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)) {
+ if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w)
+ && !"userpicker".equals(w)) {
f.setWidget("select");
}
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
@@ -764,7 +817,8 @@ public class AgentOrchestrator {
if (!opts.isEmpty()) {
f.setOptions(opts);
String w2 = f.getWidget();
- if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)) {
+ if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2)
+ && !"userpicker".equals(w2)) {
f.setWidget("select");
}
f.setOptionsFallback(false);
diff --git a/src/main/java/com/yunhai/agent/tool/FormField.java b/src/main/java/com/yunhai/agent/tool/FormField.java
index 4691187..c3d54e6 100644
--- a/src/main/java/com/yunhai/agent/tool/FormField.java
+++ b/src/main/java/com/yunhai/agent/tool/FormField.java
@@ -18,7 +18,7 @@ public class FormField {
/** 中文标签,如「开始时间」 */
private String label;
- /** 控件类型:text / textarea / number / date / datetime / select / multiselect / file / scopes / flow / radio / switch */
+ /** 控件类型:text / textarea / number / date / datetime / select / multiselect / file / scopes / flow / radio / switch / richtext / userpicker */
private String widget;
/** 数据类型:string / integer / number / boolean / array */
@@ -40,6 +40,37 @@ public class FormField {
/** {@code widget=file} 时是否允许多个附件 */
private boolean multiple;
+ /**
+ * {@code widget=richtext}(富文本正文)时,「插入图片」的上传地址(**已带模块前缀**的绝对相对路径),
+ * 如 {@code /notice/api/file/UploadImage}。
+ * 与附件同理:模块的上传接口只认浏览器 CAS 会话,必须由前端带用户登录态 POST(multipart)。
+ */
+ private String richTextUploadPath;
+
+ /**
+ * {@code widget=richtext} 时,正文内图片的预览地址模板(**已带模块前缀**,含 {@code {id}} 占位),
+ * 如 {@code /notice/api/file/Preview?id={id}} —— 前端拿到上传返回的文件 id 后替换占位符,
+ * 得到可直接放进 {@code
} 的地址。
+ */
+ private String richTextPreviewPath;
+
+ /**
+ * {@code widget=userpicker}(通知对象)的**数据源页签**,形如
+ * {@code [{name:"基础平台", call:{...}}, {name:"自定义组", call:{...}}]}。
+ *
+ *
搬运自通知公告模块页 {@code components/UserCheckModal.vue} 的两个页签,
+ * 每个 {@code call} 都已经是「**带模块前缀**的浏览器代取调用」(同 {@link #optionCall} 的形态),
+ * 由前端用用户登录态去取原始**嵌套树**(不做拍平 —— 拍平会丢掉层级,树就画不出来)。
+ */
+ private List pickerSources;
+
+ /** {@code widget=userpicker} 的一个数据源页签:页签名 + 浏览器代取调用 */
+ @lombok.Data
+ public static class PickerSource {
+ private String name;
+ private com.yunhai.agent.model.BrowserToolRequest.BrowserCall call;
+ }
+
/**
* 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。
* 前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]};
diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java
index 33437a1..6f84fd9 100644
--- a/src/main/java/com/yunhai/agent/tool/ToolParam.java
+++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java
@@ -129,6 +129,36 @@ public class ToolParam {
*/
private String uploadPathOverride;
+ /**
+ * {@code widget=richtext} 时,正文编辑器里「插入图片」用的上传地址(**模块内相对路径**)。
+ *
+ *
与 {@link #uploadPathOverride} 同约定:只写相对路径,前缀由
+ * {@code AgentOrchestrator.resolveUploadPath} 拼。通知公告模块页用的是
+ * {@code /api/file/UploadImage}(返回 {@code data[0].{id,fileTrueName}}),
+ * 与附件的 {@code /api/base/FileUpload} **不是同一个接口**。
+ */
+ private String richTextUploadOverride;
+
+ /**
+ * {@code widget=richtext} 时,正文内图片的预览地址模板(**模块内相对路径**,含 {@code {id}} 占位)。
+ *
+ *
模块页把图片 src 写成 {@code /api/file/Preview?id=}(inline 输出图片,
+ * 而 {@code /api/file/Down} 是 attachment,不能内嵌)。
+ */
+ private String richTextPreviewOverride;
+
+ /**
+ * {@code widget=userpicker} 的**数据源页签**:每项是 {@code {页签名, 模块内相对地址}}。
+ *
+ * 通知公告模块页的选人弹窗有两个页签 —— 「基础平台」({@code /api/base/GetSchoolAll} 组织树)
+ * 与「自定义组」({@code /api/contacts/GetGroupTree?type=1})。要 1:1 搬运控件,两个页签都得给。
+ * {@code optionCall} 只能描述**一个**地址,所以额外用这里下发。
+ *
+ *
★ 与 {@link #uploadPathOverride} 同约定:只写模块内相对路径(可带 query),
+ * 前缀一律由编排层拼 —— 自己带前缀就是线上 404 的老坑。
+ */
+ private List pickerSources;
+
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
private boolean hidden;
@@ -476,6 +506,38 @@ public class ToolParam {
return this;
}
+ /**
+ * {@code widget=richtext}(富文本正文):声明「插入图片」的上传地址与预览地址模板。
+ *
+ * 两者都只写**模块内相对路径**(前缀由编排层拼,与 {@link #upload} 同约定)。
+ * 例:{@code .richText("/api/file/UploadImage", "/api/file/Preview?id={id}")}
+ *
+ *
★ 为什么必须成对下发:模块的图片上传接口回的是 {@code data[0].id}(文件服务 id),
+ * 而能内嵌到正文里的地址是另一个接口({@code /api/file/Preview},inline)。
+ * 前端拿到 id 后要按模板拼出可直接放进 {@code
} 的地址。
+ */
+ public ToolParam richText(String uploadPath, String previewPath) {
+ this.richTextUploadOverride = uploadPath;
+ this.richTextPreviewOverride = previewPath;
+ return this;
+ }
+
+ /**
+ * {@code widget=userpicker}(通知对象):追加一个「选人弹窗」的数据源页签。
+ *
+ *
与 {@link #richText} / {@link #upload} 同约定:{@code pathAndQuery} 只写**模块内相对路径**
+ * (可带 query,如 {@code /api/contacts/GetGroupTree?type=1}),前缀由编排层拼。
+ *
+ *
调用顺序即页签顺序 —— 通知公告模块页是「基础平台」在前、「自定义组」在后。
+ */
+ public ToolParam pickerSource(String tabName, String pathAndQuery) {
+ if (pickerSources == null) {
+ pickerSources = new ArrayList<>();
+ }
+ pickerSources.add(new String[]{tabName, pathAndQuery});
+ return this;
+ }
+
/** 表单里显示的名字 */
public String labelOf() {
return StringUtils.isNotBlank(label) ? label : name;
@@ -578,6 +640,9 @@ public class ToolParam {
f.setSwitchOff(switchOff);
f.setRows(rows);
f.setMaxLength(maxLength);
+ // 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼)
+ f.setRichTextUploadPath(richTextUploadOverride);
+ f.setRichTextPreviewPath(richTextPreviewOverride);
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
f.setOptionsFallback(isUsingFallbackOptions());
return f;
diff --git a/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java b/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java
index bce7993..358f931 100644
--- a/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java
+++ b/src/main/java/com/yunhai/agent/tool/defs/NoticeTools.java
@@ -33,6 +33,24 @@ public final class NoticeTools {
* 这里只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveUploadPath 统一拼。 */
private static final String UPLOAD_PATH = "/api/base/FileUpload";
+ /** 正文富文本里「插入图片」的上传地址(模块页 NewNotice.vue 的 editorConfig.MENU_CONF.uploadImage)。 */
+ private static final String RICHTEXT_UPLOAD_PATH = "/api/file/UploadImage";
+
+ /** 正文内图片的预览地址模板 —— 必须是 {@code Preview}(inline 输出图片),
+ * {@code Down} 是 attachment,放进 {@code
} 显示不出来。 */
+ private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}";
+
+ /**
+ * 「通知对象」选人弹窗的两个数据源页签(模块内相对路径,前缀由编排层拼)。
+ *
+ *
逐项对齐 {@code UserCheckInput.vue} 的 {@code sources}:
+ * 「基础平台」= {@code baseApi.getSchoolAll()}({@code /api/base/GetSchoolAll}),
+ * 「自定义组」= {@code settingApi.getGroupTree()}({@code /api/contacts/GetGroupTree?type=1})。
+ * 弹窗里的树要用**原始嵌套结构**,所以这里必须给两个地址,而不是把树拍平成下拉选项。
+ */
+ private static final String[] PICKER_SOURCE_BASE = {"基础平台", "/api/base/GetSchoolAll"};
+ private static final String[] PICKER_SOURCE_GROUP = {"自定义组", "/api/contacts/GetGroupTree?type=1"};
+
private NoticeTools() {
}
@@ -99,14 +117,17 @@ public final class NoticeTools {
.label("要编辑的通知"),
ToolParam.required("title", "string", "通知标题(50 字以内)")
.label("通知标题"),
- ToolParam.optional("content", "string", "通知正文(纯文本)")
- .label("通知正文").as("textarea"),
+ ToolParam.optional("content", "string", "通知正文(富文本 HTML)")
+ .label("通知正文").as("richtext")
+ .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH),
ToolParam.optional("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
.label("来源部门")
.optionsFrom("notify.deptDics").optionsKeys("k", "v"),
ToolParam.optional("receiveList", "array", "通知对象:留空或含全部 = 全部用户")
- .label("通知对象")
+ .label("通知对象").as("userpicker")
.optionsFrom("notify.schoolAll").optionsKeys("id", "name")
+ .pickerSource(PICKER_SOURCE_BASE[0], PICKER_SOURCE_BASE[1])
+ .pickerSource(PICKER_SOURCE_GROUP[0], PICKER_SOURCE_GROUP[1])
.join(",").allOption("all", "全部用户"),
ToolParam.optional("fileIds", "array", "附件文件 id 列表")
.label("添加附件").as("file").multiple(true).join(",")
@@ -168,15 +189,22 @@ public final class NoticeTools {
List ps = new ArrayList<>();
ps.add(ToolParam.required("title", "string", "通知标题(50 字以内)")
.label("通知标题").eg("关于本周宿舍熄灯时间调整的通知"));
- ps.add(ToolParam.optional("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)")
- .label("通知正文").as("textarea").eg("各位老师:经学校研究决定……"));
+ ps.add(ToolParam.optional("content", "string", "通知正文(富文本 HTML,对应模块页的 wangEditor)")
+ .label("通知正文").as("richtext")
+ .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)
+ .eg("各位老师:经学校研究决定……"));
ps.add(ToolParam.required("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
.label("来源部门")
.optionsFrom("notify.deptDics").optionsKeys("k", "v"));
- // 模块页的 UserCheckInput:v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string)
- ps.add(ToolParam.required("receiveList", "array", "通知对象:留空或含全部 = 全部用户;也可多选具体用户")
- .label("通知对象")
+ // 模块页的 UserCheckInput:radio「全部用户 / 指定用户」+ 选人弹窗(左组织树 / 右已选列表)。
+ // v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string),**取值契约与多选完全一致**
+ // —— 换的只是控件外观。选项仍由 notify.schoolAll 代取(弹窗里的树直接用它取原始嵌套结构,
+ // 不走 parseOptionItems 的拍平,否则层级会丢)。
+ ps.add(ToolParam.required("receiveList", "array", "通知对象:\"all\" 或用户 id 逗号串")
+ .label("通知对象").as("userpicker")
.optionsFrom("notify.schoolAll").optionsKeys("id", "name")
+ .pickerSource(PICKER_SOURCE_BASE[0], PICKER_SOURCE_BASE[1])
+ .pickerSource(PICKER_SOURCE_GROUP[0], PICKER_SOURCE_GROUP[1])
.join(",").allOption("all", "全部用户"));
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
.label("添加附件").as("file").multiple(true).join(",")
diff --git a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java
index 2a8eaa7..3c94b3f 100644
--- a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java
+++ b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java
@@ -228,9 +228,14 @@ class IntentDirectFormTest {
assertEquals("/list/new", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("发布通知"), "引导语应说明这是发布通知,实际:" + resp.getReply());
- // 标题(单行)+ 正文(多行)
+ // 标题(单行)+ 正文(富文本,与模块页的 wangEditor 一致)
assertEquals("text", field(resp, "title").getWidget());
- assertEquals("textarea", field(resp, "content").getWidget());
+ FormField content = field(resp, "content");
+ assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
+ assertEquals("/notice/api/file/UploadImage", content.getRichTextUploadPath(),
+ "正文里插图片要打模块真实前缀 /notice(且不是附件的 FileUpload)");
+ assertEquals("/notice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
+ "图片预览地址模板同样要带前缀,{id} 留给前端替换");
// 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类)
FormField deptId = field(resp, "deptId");
@@ -241,11 +246,32 @@ class IntentDirectFormTest {
assertEquals("k", deptId.getValueKey());
assertEquals("v", deptId.getLabelKey());
- // 通知对象:按用户多选(与校园公告的「推送范围」按班级完全不同)
+ // 通知对象:模块页同款自绘控件(radio 全部用户/指定用户 + 选人弹窗)。
+ // ★ 它也有 optionCall,必须确认**没有被降级成 select** ——
+ // AgentOrchestrator 对「带 optionCall 的字段」会把控件改写成 select(防止取回字典后还是输入框),
+ // userpicker 靠白名单豁免;漏了就变成普通下拉,正是这次要避免的。
FormField receiveList = field(resp, "receiveList");
- assertEquals("multiselect", receiveList.getWidget());
- assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址");
+ assertEquals("userpicker", receiveList.getWidget(),
+ "通知对象应是自绘的「全部用户 / 指定用户」+ 选人弹窗,不能被降级成 select");
+ assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址(弹窗里用它取原始树)");
assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery());
+ assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵值");
+
+ // ★ 选人弹窗有两个页签(模块页 UserCheckInput.vue 的 sources)——这里钉住**拼好前缀**的结果。
+ // 与附件上传同一个坑:只写相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。
+ // 注意「自定义组」的路径里带 query,拼前缀时不能被吃掉。
+ assertNotNull(receiveList.getPickerSources(), "选人弹窗的数据源页签要下发到前端");
+ assertEquals(2, receiveList.getPickerSources().size());
+ FormField.PickerSource tab0 = receiveList.getPickerSources().get(0);
+ assertEquals("基础平台", tab0.getName());
+ assertNotNull(tab0.getCall());
+ assertEquals("GET", tab0.getCall().getMethod(), "取组织树是只读 GET");
+ assertEquals("/notice/api/base/GetSchoolAll", tab0.getCall().getPathAndQuery(),
+ "代取地址必须带模块前缀 /notice");
+ FormField.PickerSource tab1 = receiveList.getPickerSources().get(1);
+ assertEquals("自定义组", tab1.getName());
+ assertEquals("/notice/api/contacts/GetGroupTree?type=1", tab1.getCall().getPathAndQuery(),
+ "自定义组树同样要带前缀,且 type=1 要保留");
// 校园公告独有的字段绝不能串到通知公告的表单上
for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) {
diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
index e54ac93..9288cc9 100644
--- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
+++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java
@@ -539,12 +539,22 @@ class ModuleFormParityTest {
// 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue)
ToolParam receive = param(def, "receiveList");
assertNotNull(receive, "通知公告按用户下发,必须有 receiveList");
- // ★ 用 resolveWidget() 而不是 getWidget():receiveList 没有显式 .as("multiselect"),
- // 它是「type=array + optionsFrom 字典」自然推断出的多选(见 ToolParam#resolveWidget)。
- assertEquals("multiselect", receive.resolveWidget());
+ // ★ 2026-10-14:控件换成模块页同款的自绘组件(radio 全部/指定 + 选人弹窗),
+ // 故这里是**显式** .as("userpicker"),不再靠 type/optionsFrom 推断。
+ assertEquals("userpicker", receive.resolveWidget(),
+ "通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗)");
assertEquals(",", receive.getJoinSeparator());
assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all");
- assertEquals("notify.schoolAll", receive.getOptionsFrom());
+ assertEquals("notify.schoolAll", receive.getOptionsFrom(),
+ "弹窗里的组织树仍由 notify.schoolAll 代取(前端取**原始嵌套树**,不拍平)");
+
+ // 正文换成富文本(模块页 NewNotice.vue 用 wangEditor v5),并带上「插入图片」的两个地址
+ ToolParam content = param(def, "content");
+ assertEquals("richtext", content.resolveWidget(), "正文用富文本编辑器");
+ assertEquals("/api/file/UploadImage", content.getRichTextUploadOverride(),
+ "图片上传是独立接口(不是附件的 FileUpload);只写相对路径,前缀由编排层拼");
+ assertEquals("/api/file/Preview?id={id}", content.getRichTextPreviewOverride(),
+ "正文内图片必须用 Preview(inline),Down 是 attachment 内嵌显示不出来");
// 校园公告那一套字段一个都不该出现在这里
assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段");
@@ -620,6 +630,19 @@ class ModuleFormParityTest {
assertEquals("101", com.alibaba.fastjson.JSON
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
"哨兵与具体项互斥:同时选中时具体项优先");
+
+ /* ★ 2026-10-14:前端换成 UserCheckInput 后,表单里存的就是**字符串**("all" / "101,102"),
+ * 不再是数组。这条短路极易出错:normalizeJsonArgs 的第二个循环会把「array 型参数却是标量」
+ * 包成 ["101,102"](整串塞进一个元素),而模块的 NoticeRequest.receiveList 是 String —— 判非法。
+ * 之所以现在没事,是因为第一个循环按 joinSeparator 拼过之后就 continue 了。
+ * 把这个「字符串原样透传」的口径钉住,防止谁把那个 continue 删掉。 */
+ args.put("receiveList", "101,102");
+ assertEquals("101,102", com.alibaba.fastjson.JSON
+ .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
+ "userpicker 直接给逗号串时不能再被包成数组");
+ args.put("receiveList", "all");
+ assertEquals("all", com.alibaba.fastjson.JSON
+ .parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"));
}
/* ==================== 辅助 ==================== */
diff --git a/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java b/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java
index b0abb70..c745bdd 100644
--- a/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java
+++ b/src/test/java/com/yunhai/agent/NotifyIntentFormTest.java
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestTemplate;
import java.util.Arrays;
import java.util.HashSet;
+import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -131,13 +132,28 @@ class NotifyIntentFormTest {
void notifyPublishCarriesCommaJoinedReceiveListWithAllSentinel() {
ToolDef publish = registry.get("notify.publish");
ToolParam receiveList = param(publish, "receiveList");
- assertEquals("multiselect", receiveList.resolveWidget(),
- "通知对象按用户多选(校园公告那边是按班级的 scopes 控件)");
+ assertEquals("userpicker", receiveList.resolveWidget(),
+ "通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗),"
+ + "不再是通用多选下拉;取值契约不变");
assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串");
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵是 all");
assertEquals("notify.schoolAll", receiveList.getOptionsFrom());
assertEquals("id", receiveList.getOptionValueKey());
assertEquals("name", receiveList.getOptionLabelKey());
+
+ // ★ 选人弹窗的**两个页签**(照搬 UserCheckInput.vue 的 sources):
+ // optionCall 只能描述一个地址,所以「自定义组」由 pickerSource 补。
+ // 两者都只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveModulePath 拼 ——
+ // 这里钉住这一点:漏拼前缀就是线上 404(附件上传已经因此出过两次事)。
+ List sources = receiveList.getPickerSources();
+ assertNotNull(sources, "通知对象的选人弹窗要有数据源页签");
+ assertEquals(2, sources.size(), "模块页有两个页签:基础平台 / 自定义组");
+ assertEquals("基础平台", sources.get(0)[0]);
+ assertEquals("/api/base/GetSchoolAll", sources.get(0)[1],
+ "只写模块内相对路径,不带 /notice 前缀");
+ assertEquals("自定义组", sources.get(1)[0]);
+ assertEquals("/api/contacts/GetGroupTree?type=1", sources.get(1)[1],
+ "自定义组树带 type=1(与 settingApi.getGroupTree 一致)");
}
/* ==================== 下拉选项的代取地址(必须带模块前缀 /notice) ==================== */