From c084c978aa7e55caa9db0fcc26e7106bcf4941f5 Mon Sep 17 00:00:00 2001 From: lijing Date: Wed, 14 Oct 2026 15:19:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=80=9A=E7=9F=A5=E5=85=AC=E5=91=8A):=20?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=A1=A8=E5=8D=95=E6=94=AF=E6=8C=81=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E9=A1=B5=E7=9A=84=E5=AF=8C=E6=96=87=E6=9C=AC=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E4=B8=8E=E9=80=89=E4=BA=BA=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端已把 NoticeFront 的 wangEditor 与自绘「通知对象」控件 1:1 搬进助手表单, 后端补上这两个控件所需的契约: - ToolParam / FormField 新增: · pickerSources:选人弹窗的**数据源页签**(形如 {name, 模块内相对地址}), 由 AgentOrchestrator 拼上模块前缀后下发(沿用「只写相对路径」的铁律); · richTextUploadPath / richTextPreviewPath:正文插图的上传与预览地址。 - AgentOrchestrator:把 resolveUploadPath 抽出通用形态 resolveModulePath(幂等、只接受 模块内相对路径);buildForm 为 widget=userpicker 下发 pickerSources; optionCall 强制改写 widget 的白名单放行 userpicker(否则会被降级成 select)。 - NoticeTools:notify.publish 与 notify.editNotice 的 receiveList 改为 userpicker, 两个数据源为「基础平台」/api/base/GetSchoolAll 与「自定义组」 /api/contacts/GetGroupTree?type=1;content 走 richtext。 测试:NotifyIntentFormTest / IntentDirectFormTest 补 pickerSources 断言 (**只写相对路径**,不带前缀 —— 前缀拼错是本项目反复出过的坑), ModuleFormParityTest 补 userpicker 取值口径断言(字符串原样透传,不得包成数组)。 mvn -o test:Tests run: 135, Failures: 0, Errors: 0,BUILD SUCCESS。 --- .../agent/service/AgentOrchestrator.java | 74 ++++++++++++++++--- .../java/com/yunhai/agent/tool/FormField.java | 33 ++++++++- .../java/com/yunhai/agent/tool/ToolParam.java | 65 ++++++++++++++++ .../yunhai/agent/tool/defs/NoticeTools.java | 44 +++++++++-- .../yunhai/agent/IntentDirectFormTest.java | 36 +++++++-- .../yunhai/agent/ModuleFormParityTest.java | 31 +++++++- .../yunhai/agent/NotifyIntentFormTest.java | 20 ++++- 7 files changed, 273 insertions(+), 30 deletions(-) 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) ==================== */