diff --git a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java index c0a2fd2..a8830f0 100644 --- a/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java +++ b/src/main/java/com/yunhai/agent/service/AgentOrchestrator.java @@ -720,6 +720,31 @@ public class AgentOrchestrator { return prefix + path; } + /** + * 是否是「自绘选择控件」——这些控件的选项**不是**下拉字典,而是弹窗里的一棵树/一张表, + * 所以既不能被 optionCall 的降级逻辑改写成 select,也不走下拉的拍平。 + * + *

{@code userpicker} 通知对象(组织树)· {@code scopepicker} 接收班级(年级→班级树) + * · {@code roompicker} 接收宿舍(楼栋→房间)。 + * + *

{@code public} 是为了让契约测试能拿**生产口径**去断言 + * (「声明了 pickerSources 就必须是自绘控件」),避免测试里再抄一份白名单。 + */ + public static boolean isSelfDrawnPicker(String widget) { + return "userpicker".equals(widget) || "scopepicker".equals(widget) || "roompicker".equals(widget); + } + + /** 自绘控件弹窗的标题前缀(仅用于后端内部命名的可读性,前端各自有自己的标题) */ + private static String pickerTitle(String widget) { + if ("scopepicker".equals(widget)) { + return "选择班级"; + } + if ("roompicker".equals(widget)) { + return "选择宿舍"; + } + return "选择用户"; + } + /** 由工具定义生成补全表单 */ private FormRequest buildForm(ToolDef def, Map args, List missing, CallerContext caller) { @@ -759,15 +784,21 @@ public class AgentOrchestrator { } // 富文本正文:正文里「插入图片」用的是**另一个**接口(UploadImage / Preview), // 与附件的 FileUpload 不是一回事,所以单独下发两条(同样只写相对路径,前缀在这里拼)。 + // + // ★ 没有 `relFallback`:正文图片上传地址**必须由工具显式声明**(`.richText(upload, preview)`), + // 不声明就是**空**。若沿用附件的 `/api/base/FileUpload` 兜底,就会给「模块里根本没有 + // 这个接口」的富文本(专业发展:file 域只有 Upload/Preview/Down)下发一个错地址, + // 用户点了插入图片只会拿到 404。空地址时前端把工具栏的「图片」组去掉。 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())); + f.setRichTextUploadPath(resolveModulePath(def.getModule(), baseUrl, p.getRichTextUploadOverride(), null)); + f.setRichTextPreviewPath(resolveModulePath(def.getModule(), baseUrl, p.getRichTextPreviewOverride(), null)); } - // 「通知对象」自绘控件(userpicker)的选人弹窗数据源:模块页有两个页签 - // (基础平台组织树 / 自定义组),optionCall 只能描述一个地址,其余由这里补齐。 + // 自绘选择控件(userpicker / scopepicker / roompicker)的**数据源**: + // 这些控件的弹窗要的是**原始嵌套结构**(组织树 / 班级树 / 楼栋·房间), + // optionCall 只能描述一个地址、且会被当字典拍平,所以另用 pickerSources 下发。 // 同样只写相对路径,前缀在这里拼(见 resolveModulePath)。 - if ("userpicker".equals(f.getWidget()) && p.getPickerSources() != null) { + if (isSelfDrawnPicker(f.getWidget()) && p.getPickerSources() != null) { String baseUrl = settings.getModules().get(def.getModule()); List srcs = new ArrayList<>(); for (String[] ps : p.getPickerSources()) { @@ -776,7 +807,7 @@ public class AgentOrchestrator { } BrowserToolRequest.BrowserCall call = new BrowserToolRequest.BrowserCall(); call.setName("picker." + ps[0]); - call.setTitle("选择用户 · " + ps[0]); + call.setTitle(pickerTitle(f.getWidget()) + " · " + ps[0]); call.setWrite(false); call.setMethod("GET"); call.setPathAndQuery(resolveModulePath(def.getModule(), baseUrl, ps[1], null)); @@ -802,11 +833,11 @@ public class AgentOrchestrator { // ★ 多值/自绘控件必须保留原样: // multiselect(接收班级/宿舍)、scopes(公告推送范围)、flow(公告审批流) // 都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力; - // userpicker(通知公告的「通知对象」)是**自绘控件**(radio 全部/指定 + 选人弹窗), - // 它自己会用 optionCall 去取组织树 —— 降级成 select 就等于把它改回普通下拉。 + // userpicker / scopepicker / roompicker 是**自绘控件**(radio 全部/指定 + 弹窗树), + // 它们自己会用 pickerSources 去取原始结构 —— 降级成 select 就等于把它们改回普通下拉。 String w = f.getWidget(); if (!"multiselect".equals(w) && !"scopes".equals(w) && !"flow".equals(w) - && !"userpicker".equals(w)) { + && !isSelfDrawnPicker(w)) { f.setWidget("select"); } // 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖 @@ -818,7 +849,7 @@ public class AgentOrchestrator { f.setOptions(opts); String w2 = f.getWidget(); if (!"multiselect".equals(w2) && !"scopes".equals(w2) && !"flow".equals(w2) - && !"userpicker".equals(w2)) { + && !isSelfDrawnPicker(w2)) { f.setWidget("select"); } f.setOptionsFallback(false); diff --git a/src/main/java/com/yunhai/agent/tool/ToolParam.java b/src/main/java/com/yunhai/agent/tool/ToolParam.java index 6f84fd9..ea91a2e 100644 --- a/src/main/java/com/yunhai/agent/tool/ToolParam.java +++ b/src/main/java/com/yunhai/agent/tool/ToolParam.java @@ -369,6 +369,39 @@ public class ToolParam { return this; } + /** + * 声明为「班级范围」自绘控件(widget=scopepicker)。 + * + *

与 {@link #scopes(int)} 提交口径完全相同({@code [{scopeType,scopeId,scopeName}]}), + * 区别只在**呈现**:这个控件是校园公告模块页 {@code ClassScopeInput} 的同款 + * —— radio「全部班级 / 指定班级」+ 只读回显 + 弹窗班级树(左树右已选)。 + * {@code scopes} 是普通多选下拉,模块页早就不长那样了。 + * + *

弹窗的树由 {@link #pickerSource} 下发(校园公告用的是 + * {@code /api/base/getStuTree?toUser=false},前端自底向上归成「年级→班级」两层)。 + * + * @param type 具体班级节点的 scopeType:1 全校 / 2 学部 / 3 年级 / 4 班级 + */ + public ToolParam scopePicker(int type) { + this.widget = "scopepicker"; + this.scopeType = type; + return this; + } + + /** + * 声明为「接收宿舍」自绘控件(widget=roompicker)。 + * + *

宿舍通知模块页那个自写的「选择接收宿舍」弹窗(左楼栋列表 / 右按楼层分组的房间勾选 / + * 底部已选间数与住宿生人数),提交房间 id 逗号串,或 {@link #allOption} 的全选哨兵。 + * + *

弹窗要**两个**数据源,按顺序用 {@link #pickerSource} 下发: + * ① 楼栋列表;② 房间列表(地址里可带 {@code {buildingId}} 占位,前端切楼栋时替换后再请求)。 + */ + public ToolParam roomPicker() { + this.widget = "roompicker"; + return this; + } + /** * 声明为「审批流」控件(校园公告的 {@code flow})。 * 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 9c98ce9..68a26dd 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/ActivityTools.java @@ -22,6 +22,12 @@ public final class ActivityTools { /** 活动分类字典的 type 值(/api/dict/List?type=2) */ private static final Object DICT_TYPE_CATEGORY = 2; + /** 活动说明富文本里「插入图片」的上传地址(模块页 NewActivity.vue 的 editorConfig.MENU_CONF.uploadImage)。 */ + private static final String RICHTEXT_UPLOAD_PATH = "/api/file/UploadImage"; + + /** 正文内图片的预览地址模板 —— 必须是 {@code Preview}(inline 输出图片)。 */ + private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}"; + private ActivityTools() { } @@ -110,8 +116,10 @@ public final class ActivityTools { "POST_JSON", "/api/activity/Create") .p(ToolParam.required("title", "string", "活动标题(50 字以内)") .label("活动标题").eg("秋季亲子运动会"), - ToolParam.required("content", "string", "活动说明(纯文本,对应模块页的富文本编辑器)") - .label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"), + ToolParam.required("content", "string", "活动说明(富文本 HTML,对应模块页的 wangEditor)") + .label("活动说明").as("richtext") + .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH) + .eg("为增进家校互动,学校拟举办秋季亲子运动会……"), ToolParam.required("category", "string", "活动分类:**分类名称**(模块页存的是名称,不是 id)") .label("活动分类") .optionsFrom("activity.categoryDic").optionsKeys("v", "v"), @@ -168,8 +176,9 @@ public final class ActivityTools { .label("活动"), ToolParam.required("title", "string", "活动标题") .label("活动标题"), - ToolParam.required("content", "string", "活动说明") - .label("活动说明").as("textarea")) + ToolParam.required("content", "string", "活动说明(富文本 HTML)") + .label("活动说明").as("richtext") + .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)) .fix("receiveType", "class") .fix("receiveList", "ALL") .write("/list")); 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 24e87ce..2b761ad 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/CampusNoticeTools.java @@ -13,6 +13,22 @@ public final class CampusNoticeTools { public static final String MODULE = "campusnotice"; + /** 公告正文富文本里「插入图片」的上传地址(模块页 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 ClassScopeInput.vue}:它调的正是 + * {@code baseApi.getStuTree(false)}({@code GET /api/base/getStuTree?toUser=false}), + * 前端自底向上归成「年级→班级」两层树。后端**没有** GetClassTree,别写那个地址。 + */ + private static final String[] PICKER_SOURCE_CLASS = {"班级", "/api/base/getStuTree?toUser=false"}; + private CampusNoticeTools() { } @@ -148,8 +164,10 @@ public final class CampusNoticeTools { List ps = new ArrayList<>(); ps.add(ToolParam.required("title", "string", "公告标题(200 字以内)") .label("公告标题").eg("关于国庆假期安排的通知")); - ps.add(ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)") - .label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……")); + ps.add(ToolParam.required("content", "string", "公告正文(富文本 HTML,对应模块页的 wangEditor)") + .label("公告正文").as("richtext") + .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH) + .eg("各位老师、同学:经学校研究决定……")); ps.add(ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要") .label("重要程度").dft("1") .options(new String[]{"1", "2"}, new String[]{"普通", "重要"})); @@ -160,10 +178,13 @@ public final class CampusNoticeTools { ps.add(ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)") .label("发文部门名称") .mirrorLabelOf("deptId")); + // ★ 接收班级 = 模块页 ClassScopeInput 的同款自绘控件(radio 全部/指定 + 弹窗班级树), + // 提交口径不变:仍拼成 [{scopeType,scopeId,scopeName}],只是不再用普通多选下拉。 ps.add(ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级") .label("接收班级") .optionsFrom("notice.stuTree").optionsKeys("id", "name") - .scopes(4).allOption("ALL", "全部班级")); + .scopePicker(4).allOption("ALL", "全部班级") + .pickerSource(PICKER_SOURCE_CLASS[0], PICKER_SOURCE_CLASS[1])); ps.add(ToolParam.optional("topmost", "integer", "是否置顶") .label("置顶").dft("0") .options(new String[]{"0", "1"}, new String[]{"否", "是"})); 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 9272a66..9076dbd 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/DormNoticeTools.java @@ -29,6 +29,26 @@ public final class DormNoticeTools { /** 发文部门字典的 type 值(/api/dict/List?type=dept) */ private static final String DICT_TYPE_DEPT = "dept"; + /** 通知正文富文本里「插入图片」的上传地址(模块页 NewNotice.vue 的 editorConfig.MENU_CONF.uploadImage)。 */ + private static final String RICHTEXT_UPLOAD_PATH = "/api/file/UploadImage"; + + /** 正文内图片的预览地址模板 —— 必须是 {@code Preview}(inline 输出图片)。 */ + private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}"; + + /** + * 「接收宿舍」弹窗的两个数据源,**顺序即角色**(与模块页 NewNotice.vue 的 + * {@code openRoomPicker()} + {@code loadRooms()} 一一对应): + *

    + *
  1. 楼栋列表 —— {@code dormApi.getBuildingList()}(含房间数/床位数/入住数/宿管);
  2. + *
  3. 房间列表 —— {@code dormApi.getRoomList(buildingId)}(**buildingId 可缺省**: + * 模块页回显时就是不带参数调的,一次取回全部房间用于补全箱名/床位数)。 + * 前端切楼栋时自行追加 {@code ?buildingId=},所以这里只给裸地址。
  4. + *
+ * 同样只写模块内相对路径,前缀由编排层拼。 + */ + private static final String[] PICKER_SOURCE_BUILDING = {"楼栋", "/api/dorm/BuildingList"}; + private static final String[] PICKER_SOURCE_ROOM = {"房间", "/api/dorm/RoomList"}; + private DormNoticeTools() { } @@ -105,8 +125,10 @@ public final class DormNoticeTools { "POST_JSON", "/api/notice/Create") .p(ToolParam.required("title", "string", "通知标题(50 字以内)") .label("通知标题").eg("关于宿舍熄灯时间调整的通知"), - ToolParam.required("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)") - .label("通知正文").as("textarea").eg("各位家长:自本周起,宿舍熄灯时间调整为 22:30……"), + ToolParam.required("content", "string", "通知正文(富文本 HTML,对应模块页的 wangEditor)") + .label("通知正文").as("richtext") + .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH) + .eg("各位家长:自本周起,宿舍熄灯时间调整为 22:30……"), ToolParam.required("noticeType", "string", "通知类型,来自 dorm.noticeTypeDic 的 k 字段") .label("通知类型") .optionsFrom("dorm.noticeTypeDic").optionsKeys("k", "v"), @@ -132,9 +154,15 @@ public final class DormNoticeTools { ToolParam.optional("formType", "string", "回执单据类型(receiptMode=fill 时必填),来自 dorm.formTemplates 的 type 字段") .label("回执单据") .optionsFrom("dorm.formTemplates").optionsKeys("type", "name"), + // ★ 接收宿舍 = 模块页那个自写的「选择接收宿舍」弹窗同款控件 + // (左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数)。 + // 提交口径不变:房间 id 逗号串,或全选哨兵 "ALL"。 ToolParam.optional("receiveList", "array", "接收宿舍:房间 id 列表;留空或含 ALL = 全部宿舍") .label("接收宿舍") .optionsFrom("dorm.rooms").optionsKeys("roomId", "buildingName+roomNo") + .roomPicker() + .pickerSource(PICKER_SOURCE_BUILDING[0], PICKER_SOURCE_BUILDING[1]) + .pickerSource(PICKER_SOURCE_ROOM[0], PICKER_SOURCE_ROOM[1]) .join(",").allOption("ALL", "全部宿舍"), ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅需回执时有意义)") .label("宿管到场配合").dft("0") @@ -164,8 +192,9 @@ public final class DormNoticeTools { .label("通知"), ToolParam.required("title", "string", "通知标题") .label("通知标题"), - ToolParam.required("content", "string", "通知正文") - .label("通知正文").as("textarea")) + ToolParam.required("content", "string", "通知正文(富文本 HTML)") + .label("通知正文").as("richtext") + .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)) .fix("receiveType", "room") .fix("receiveList", "ALL") .write("/list")); diff --git a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java index 473f0c3..9b15958 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/HomeworkTools.java @@ -34,6 +34,13 @@ public final class HomeworkTools { */ private static final String UPLOAD_PATH = "/api/file/Upload"; + /** 作业内容富文本里「插入图片」的上传地址(模块页 NewHomework.vue 的 editorConfig.MENU_CONF.uploadImage)。 + * 注意与附件的 {@link #UPLOAD_PATH} **不是同一个接口**:正文图走 file/UploadImage。 */ + private static final String RICHTEXT_UPLOAD_PATH = "/api/file/UploadImage"; + + /** 正文内图片的预览地址模板 —— 必须是 {@code Preview}(inline 输出图片)。 */ + private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}"; + private HomeworkTools() { } @@ -159,8 +166,10 @@ public final class HomeworkTools { ps.add(ToolParam.required("classIds", "array", "接收班级:班级 id 列表(仅本人任教班级,可多选)") .label("接收班级") .optionsFrom("homework.classTree").optionsKeys("id", "name")); - ps.add(ToolParam.optional("description", "string", "作业内容(纯文本,对应模块页的富文本编辑器)") - .label("作业内容").as("textarea").eg("完成课本 P32 第 1~5 题,写出解题过程。")); + ps.add(ToolParam.optional("description", "string", "作业内容(富文本 HTML,对应模块页的 wangEditor)") + .label("作业内容").as("richtext") + .richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH) + .eg("完成课本 P32 第 1~5 题,写出解题过程。")); ps.add(ToolParam.optional("deadlineAt", "string", "截止时间 yyyy-MM-dd HH:mm:ss(留空=不设截止)") .label("截止时间").eg("2026-10-20 22:00:00")); ps.add(ToolParam.optional("submitType", "array", diff --git a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java index dfce231..712079e 100644 --- a/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java +++ b/src/main/java/com/yunhai/agent/tool/defs/ProfessionTools.java @@ -182,8 +182,13 @@ public final class ProfessionTools { ps.add(ToolParam.optional("ifOpen", "integer", "是否公开:1 公开 / 0 不公开") .label("是否公开").as("radio").dft("1") .options(new String[]{"1", "0"}, new String[]{"公开", "不公开"})); - ps.add(ToolParam.optional("remark", "string", "正文内容(对应模块页的富文本编辑器)") - .label("正文内容").as("textarea").placeholder("请输入正文内容…")); + // ★ 富文本但**不带图片上传**:模块页 RecordEdit.vue 的 editorConfig 只有 placeholder + // (toolbarConfig = {}),并没有配 MENU_CONF.uploadImage —— 该模块的 file 域只暴露了 + // Upload / Preview / Down(见 profession.ts),**没有** UploadImage。 + // 所以这里不调 .richText(...):上传地址为空时前端会把工具栏的「图片」组去掉, + // 而不是留一个点了必失败的按钮。 + ps.add(ToolParam.optional("remark", "string", "正文内容(富文本 HTML,对应模块页的 wangEditor;该模块不支持正文插图)") + .label("正文内容").as("richtext").placeholder("请输入正文内容…")); // 栏目动态字段的打包结果:由表单取回字段定义、渲染后自行生成,用户不直接填 ps.add(ToolParam.optional("contents", "string", "栏目动态字段的取值(JSON 数组,由表单自动生成)") .label("栏目动态字段").hide()); diff --git a/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java b/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java index d23f1e0..970d872 100644 --- a/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java +++ b/src/test/java/com/yunhai/agent/DormActivityPublishIntentTest.java @@ -30,8 +30,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * 本轮起「发通知 / 发布通知」这类不带限定的说法不再直达 —— 校园公告与宿舍通知 * 都有发布工具,同一句「发通知」是跨模块歧义(猜错模块比不直达更糟),交给模型按上下文选。 *
  • 仅教职工:两个发布工具都归 STAFF_ONLY,学生身份没有落点 → 落回模型如实说明。
  • - *
  • 接收对象固定为「全部」:{@code receiveType}/{@code receiveList} 由 {@code fix} 写死, - * 房间/班级级定向请到模块页面里勾选,避免表单里出现难用的 id 输入框。
  • + *
  • 接收对象的「级别」固定,具体对象不固定:{@code receiveType} 由 {@code fix} 写死 + * (宿舍=房间级 / 活动=班级级);{@code receiveList} 在表单里选、默认「全部」。 + *
    ★ 2026-10-14:宿舍的接收对象不再是通用多选下拉,而是搬了模块页那块自写的 + * 「选择接收宿舍」弹窗({@code widget=roompicker},左楼栋 / 右按楼层分组的房间)—— + * 早先「只给全部、定向去模块页勾」的做法已作废,用户不必再跳出去。
  • * */ class DormActivityPublishIntentTest { @@ -123,13 +126,26 @@ class DormActivityPublishIntentTest { assertTrue(required.contains("noticeType"), "通知类型是发布表单必填项"); assertTrue(required.contains("deptId"), "发文部门是发布表单必填项"); - // 接收对象:receiveType 固定为「房间」级;receiveList 由表单多选(默认「全部宿舍」) + // 接收对象:receiveType 固定为「房间」级;receiveList 由表单选择(默认「全部宿舍」)。 + // ★ 2026-10-14:控件换成宿舍模块页那块自写的「选择接收宿舍」弹窗同款(widget=roompicker)。 + // 取值契约**不变**(房间 id 逗号串 / ALL 哨兵),所以下面几条断言照旧。 assertEquals("room", publish.getFixed().get("receiveType")); ToolParam receiveList = param(publish, "receiveList"); assertNotNull(receiveList, "接收宿舍应作为可选字段出现在表单里(不再写死)"); + assertEquals("roompicker", receiveList.resolveWidget(), + "接收宿舍用模块页同款房间弹窗(左楼栋 / 右按楼层分组的房间勾选),不再是通用多选下拉"); assertEquals("ALL", receiveList.getAllToken(), "「全部宿舍」哨兵是 ALL"); assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串"); assertEquals("dorm.rooms", receiveList.getOptionsFrom()); + // 弹窗要**两个**数据源(主从,不是页签):① 楼栋列表 ② 房间列表。 + // 与附件上传同一个坑:只写模块内相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。 + List sources = receiveList.getPickerSources(); + assertNotNull(sources, "房间弹窗的楼栋 / 房间数据源要下发到前端"); + assertEquals(2, sources.size()); + assertEquals("楼栋", sources.get(0)[0]); + assertEquals("/api/dorm/BuildingList", sources.get(0)[1]); + assertEquals("房间", sources.get(1)[0]); + assertEquals("/api/dorm/RoomList", sources.get(1)[1]); assertTrue(publish.isFormAll(), "发布表单应渲染全部字段(与模块「新建通知」页一致)"); } @@ -230,11 +246,23 @@ class DormActivityPublishIntentTest { "smsFlag", "receiptFlag", "fileIds", "timingSendTime", "publishType")) { assertTrue(names.contains(k), "公告发布表单缺少字段(模块页有):" + k); } - // 接收班级是「推送范围」控件({scopeType,scopeId,scopeName}),默认含「全部」 + // 接收班级是「推送范围」控件({scopeType,scopeId,scopeName}),默认含「全部」。 + // ★ 2026-10-14:控件换成校园公告模块页 ClassScopeInput 的同款自绘形态 + // (radio「全部班级 / 指定班级」+ 弹窗班级树),**提交口径不变** —— + // 仍是那个对象数组,所以 scopeType / allToken 两条断言照旧。 ToolParam scopes = param(registry.get("notice.publish"), "pushScopes"); - assertEquals("scopes", scopes.resolveWidget()); + assertEquals("scopepicker", scopes.resolveWidget(), + "接收班级用模块页同款自绘控件,不再是普通多选下拉"); assertEquals(Integer.valueOf(4), scopes.getScopeType(), "班级节点 scopeType=4"); assertEquals("ALL", scopes.getAllToken()); + // 弹窗的树只有**一个**数据源(scopepicker 是单页签,与 userpicker 的多页签不同): + // 校园公告模块没有 GetClassTree,走 /api/base/getStuTree?toUser=false, + // 前端自底向上归成「年级→班级」两层。同样只写相对路径,前缀由编排层拼。 + List tree = scopes.getPickerSources(); + assertNotNull(tree, "班级树的取数地址要下发到前端"); + assertEquals(1, tree.size()); + assertEquals("班级", tree.get(0)[0]); + assertEquals("/api/base/getStuTree?toUser=false", tree.get(0)[1]); } @Test diff --git a/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java b/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java index 919f59f..d4f7edf 100644 --- a/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java +++ b/src/test/java/com/yunhai/agent/FormFieldWidgetTest.java @@ -183,4 +183,82 @@ class FormFieldWidgetTest { assertEquals(4, f.getRows().intValue()); assertEquals(1000, f.getMaxLength().intValue()); } + + /** + * ⑪ 富文本正文:控件形态 + **插图地址必须原样下发**,且「不声明 = 空」。 + * + *

    两个反向断言都是踩过的坑: + *

    + */ + @Test + void richTextWidgetCarriesUploadAndPreviewPathsOnlyWhenDeclared() { + FormField withImages = ToolParam.required("content", "string", "公告正文") + .as("richtext") + .richText("/api/file/UploadImage", "/api/file/Preview?id={id}") + .toFormField(); + + assertEquals("richtext", withImages.getWidget()); + assertEquals("/api/file/UploadImage", withImages.getRichTextUploadPath()); + assertEquals("/api/file/Preview?id={id}", withImages.getRichTextPreviewPath()); + + // 只声明形态、不声明地址 —— 地址必须为空(前缀由编排层拼,不在这里兜底) + FormField noImages = ToolParam.optional("remark", "string", "正文内容") + .as("richtext") + .toFormField(); + + assertEquals("richtext", noImages.getWidget()); + assertNull(noImages.getRichTextUploadPath(), "不声明 .richText(...) 时上传地址必须是空,不能兜底"); + assertNull(noImages.getRichTextPreviewPath()); + } + + /** + * ⑫ 自绘「接收对象」控件:三种形态各自透传,且 pickerSources 的页签名与地址一一对应。 + * + *

    ⚠️ 这里只断言**工具层声明**({@code ToolParam.pickerSources} 的原始 + * {@code {页签名, 模块内相对路径}}),不断言 {@code FormField.getPickerSources()} —— + * 后者的 {@code call} 是在**编排层**把相对路径拼上前缀后才建出来的, + * 纯单元测试里 {@code toFormField()} 根本不会填它(误断言会拿到 null)。 + * 「拼好前缀之后」的那一层由 {@code ModuleFormParityTest} 的通用不变量兜住。 + */ + @Test + void selfDrawnPickersPassThroughWidgetAndSources() { + ToolParam scope = ToolParam.optional("pushScopes", "array", "接收班级") + .label("接收班级").scopePicker(4) + .pickerSource("班级", "/api/base/getStuTree?toUser=false") + .allOption("ALL", "全部班级"); + + FormField scopeField = scope.toFormField(); + assertEquals("scopepicker", scopeField.getWidget()); + assertEquals(Integer.valueOf(4), scopeField.getScopeType()); + assertEquals("ALL", scopeField.getAllToken()); + assertEquals("全部班级", scopeField.getAllTokenLabel()); + assertEquals(1, scope.getPickerSources().size(), "scopepicker 是单页签(班级)"); + assertEquals("班级", scope.getPickerSources().get(0)[0]); + assertEquals("/api/base/getStuTree?toUser=false", scope.getPickerSources().get(0)[1], + "只写模块内相对路径,前缀留给编排层"); + + ToolParam room = ToolParam.optional("receiveList", "array", "接收宿舍") + .label("接收宿舍").roomPicker() + .pickerSource("楼栋", "/api/dorm/BuildingList") + .pickerSource("房间", "/api/dorm/RoomList") + .allOption("ALL", "全部宿舍"); + + FormField roomField = room.toFormField(); + assertEquals("roompicker", roomField.getWidget()); + assertEquals("ALL", roomField.getAllToken()); + assertEquals(2, room.getPickerSources().size(), "房间弹窗是「楼栋 + 房间」两个数据源"); + assertEquals("楼栋", room.getPickerSources().get(0)[0]); + assertEquals("/api/dorm/BuildingList", room.getPickerSources().get(0)[1]); + assertEquals("房间", room.getPickerSources().get(1)[0]); + assertEquals("/api/dorm/RoomList", room.getPickerSources().get(1)[1]); + + // 没声明数据源的普通字段不能凭空长出 pickerSources + assertNull(ToolParam.required("title", "string", "标题").getPickerSources()); + } } diff --git a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java index 3c94b3f..17f662d 100644 --- a/src/test/java/com/yunhai/agent/IntentDirectFormTest.java +++ b/src/test/java/com/yunhai/agent/IntentDirectFormTest.java @@ -190,9 +190,14 @@ class IntentDirectFormTest { assertEquals("/list/new", resp.getForm().getLandingPath()); assertTrue(resp.getReply().contains("发布"), "引导语应说明这是发布,实际:" + resp.getReply()); - // 公告表单:标题(单行)+ 正文(多行)+ 发文部门(下拉) + // 公告表单:标题(单行)+ 正文(富文本,与模块页 NewNotice.vue 的 wangEditor 一致)+ 发文部门(下拉) assertEquals("text", field(resp, "title").getWidget()); - assertEquals("textarea", field(resp, "content").getWidget()); + FormField content = field(resp, "content"); + assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)"); + assertEquals("/campusnotice/api/file/UploadImage", content.getRichTextUploadPath(), + "正文插图要走模块真实前缀 /campusnotice(且不是附件的 FileUpload)"); + assertEquals("/campusnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath(), + "图片预览地址模板同样要带前缀,{id} 留给前端替换"); FormField deptId = field(resp, "deptId"); assertEquals("select", deptId.getWidget(), "发文部门应是下拉,不该让用户手填部门编号"); @@ -300,9 +305,13 @@ class IntentDirectFormTest { assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertEquals("/list/new", resp.getForm().getLandingPath()); - // 标题 + 正文 + 通知类型(下拉)+ 发文部门(下拉) + // 标题 + 正文(富文本,模块页 NewNotice.vue 用 wangEditor)+ 通知类型(下拉)+ 发文部门(下拉) assertEquals("text", field(resp, "title").getWidget()); - assertEquals("textarea", field(resp, "content").getWidget()); + FormField content = field(resp, "content"); + assertEquals("richtext", content.getWidget(), "正文用富文本编辑器(模块页 wangEditor)"); + assertEquals("/dormnotice/api/file/UploadImage", content.getRichTextUploadPath(), + "正文插图走模块真实前缀 /dormnotice"); + assertEquals("/dormnotice/api/file/Preview?id={id}", content.getRichTextPreviewPath()); FormField noticeType = field(resp, "noticeType"); assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号"); @@ -336,9 +345,14 @@ class IntentDirectFormTest { assertTrue(resp.getForm().isWrite()); assertEquals("/list/new", resp.getForm().getLandingPath()); - // 活动表单:标题 + 说明(多行)+ 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间 + // 活动表单:标题 + 说明(**富文本**,模块页 PublishActivity.vue 用 wangEditor)+ + // 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间 assertEquals("text", field(resp, "title").getWidget()); - assertEquals("textarea", field(resp, "content").getWidget()); + FormField content = field(resp, "content"); + assertEquals("richtext", content.getWidget(), "活动说明用富文本编辑器(模块页 wangEditor)"); + assertEquals("/familyactivity/api/file/UploadImage", content.getRichTextUploadPath(), + "正文插图走模块真实前缀 /familyactivity(不是附件的 FileUpload)"); + assertEquals("/familyactivity/api/file/Preview?id={id}", content.getRichTextPreviewPath()); FormField category = field(resp, "category"); assertEquals("select", category.getWidget()); diff --git a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java index 9288cc9..3373e35 100644 --- a/src/test/java/com/yunhai/agent/ModuleFormParityTest.java +++ b/src/test/java/com/yunhai/agent/ModuleFormParityTest.java @@ -8,6 +8,7 @@ import com.yunhai.agent.tool.ToolDef; import com.yunhai.agent.tool.ToolExecutor; import com.yunhai.agent.tool.ToolParam; import com.yunhai.agent.tool.ToolRegistry; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.client.RestTemplate; @@ -340,6 +341,114 @@ class ModuleFormParityTest { assertTrue(checked >= 6, "六个模块的附件字段都应被覆盖到,实际只检查了 " + checked + " 个"); } + /** + * 同一条不变量的两张新面孔:**正文图片地址**与**自绘控件的弹窗数据源**。 + * + *

    它们和附件上传是同一个坑的三种形态(只写模块内相对路径、前缀由编排层拼), + * 早先的通用不变量只遍历 {@code widget=file} 的字段,于是这两类新增地址 + * 天然落在覆盖面之外 —— 一旦有人写成绝对路径或漏声明,测试是绿的、线上 404。 + * + *

    判据(三类共用):① 工具里写的必须是模块内相对路径(不带空前缀); + * ② 拼完前缀后必须以空前缀开头;③ 不能出现双斜杠。 + * ④ 另外钉住「正文图片地址没有兜底」——不声明就是空,不能凭空变成附件的 FileUpload。 + */ + @Test + void noRichTextOrPickerPathIsEverEmittedWithoutItsModulePrefix() { + int richChecked = 0; + int pickerChecked = 0; + + for (ToolDef def : registry.all()) { + String baseUrl = settings.getModules().get(def.getModule()); + if (baseUrl == null) { + continue; + } + String prefix = AgentOrchestrator.pathPrefix(def.getModule(), baseUrl); + + for (ToolParam p : def.getParams()) { + // ---- ① 正文图片:上传 + 预览两条地址 ---- + if (!"richtext".equals(p.resolveWidget()) || StringUtils.isBlank(p.getRichTextUploadOverride())) { + continue; + } + assertFalse(p.getRichTextUploadOverride().startsWith(prefix), + def.getName() + " 的 .richText() 只写模块内相对路径(如 /api/file/UploadImage)," + + "前缀由编排层拼,实际写了:" + p.getRichTextUploadOverride()); + + String up = AgentOrchestrator.resolveModulePath(def.getModule(), baseUrl, + p.getRichTextUploadOverride(), null); + assertTrue(up.startsWith(prefix + "/"), + def.getName() + " 的正文图片上传地址必须带模块前缀 " + prefix + ",实际:" + up); + assertFalse(up.contains("//"), def.getName() + " 的正文图片上传地址不能出现双斜杠:" + up); + + // 预览地址是可选的(有的模块只上传不预览),声明了就得同样带前缀; + // 且 {id} 占位符必须原样保留,否则前端替换不出来。 + String previewRel = p.getRichTextPreviewOverride(); + if (StringUtils.isNotBlank(previewRel)) { + String pv = AgentOrchestrator.resolveModulePath(def.getModule(), baseUrl, previewRel, null); + assertTrue(pv.startsWith(prefix + "/"), + def.getName() + " 的正文图片预览地址必须带模块前缀 " + prefix + ",实际:" + pv); + assertFalse(pv.contains("//"), def.getName() + " 的正文图片预览地址不能出现双斜杠:" + pv); + assertTrue(pv.contains("{id}"), + def.getName() + " 的预览地址要保留 {id} 占位符交给前端替换,实际:" + pv); + } + richChecked++; + } + + for (ToolParam p : def.getParams()) { + // ---- ② 自绘控件(userpicker / scopepicker / roompicker)的弹窗数据源 ---- + if (p.getPickerSources() == null || p.getPickerSources().isEmpty()) { + continue; + } + assertTrue(AgentOrchestrator.isSelfDrawnPicker(p.resolveWidget()), + def.getName() + " 的 " + p.getName() + " 声明了 pickerSources 却不是自绘控件(" + + p.resolveWidget() + ")—— 前端不会渲染弹窗"); + for (String[] ps : p.getPickerSources()) { + if (ps == null || ps.length < 2 || StringUtils.isBlank(ps[1])) { + continue; + } + assertFalse(ps[1].startsWith(prefix), + def.getName() + " 的 pickerSource(" + ps[0] + ") 只写模块内相对路径,实际写了:" + ps[1]); + String path = AgentOrchestrator.resolveModulePath(def.getModule(), baseUrl, ps[1], null); + assertTrue(path.startsWith(prefix + "/"), + def.getName() + " 的弹窗数据源「" + ps[0] + "」必须带模块前缀 " + prefix + ",实际:" + path); + assertFalse(path.contains("//"), + def.getName() + " 的弹窗数据源「" + ps[0] + "」不能出现双斜杠:" + path); + pickerChecked++; + } + } + } + + // 覆盖面下限:五个模块的正文(专业发展**故意不声明**,它没有 UploadImage)+ + // 选人(通知公告)/ 选班级(校园公告)/ 选宿舍(宿舍通知)三处的弹窗数据源。 + assertTrue(richChecked >= 4, + "5 个模块里的正文都应声明插图地址(专业发展是刻意的例外),实际只检查了 " + richChecked + " 个"); + assertTrue(pickerChecked >= 4, + "选人 2 个页签 + 选班级 1 个 + 选宿舍 2 个 = 5 组数据源应被覆盖,实际只检查了 " + pickerChecked + " 个"); + } + + /** + * 「正文图片地址没有兜底」的反面:**专业发展**的正文是富文本,但该模块 file 域 + * 只有 Upload / Preview / Down(没有 UploadImage),模块页的 editorConfig 也没配 + * uploadImage ⇒ 工具不声明 {@code .richText(...)},下发的上传地址必须是**空**, + * 前端据此把工具栏的「图片」整组摘掉。 + * + *

    若哪天有人「好心」给它加一个 /api/base/FileUpload 兜底,就会长出一个模块页 + * 根本没有的图片按钮,用户点了必然 404 —— 这条专门拦这个。 + */ + @Test + void professionRichTextMustNotGetAFallbackImageUpload() { + ToolDef def = registry.get("profession.publish"); + ToolParam remark = param(def, "remark"); + assertEquals("richtext", remark.resolveWidget()); + assertNull(remark.getRichTextUploadOverride(), + "专业发展不支持正文插图,不能声明上传地址(更不该有兜底)"); + assertNull(remark.getRichTextPreviewOverride()); + + String baseUrl = settings.getModules().get("profession"); + assertEquals("", AgentOrchestrator.resolveModulePath("profession", baseUrl, + remark.getRichTextUploadOverride(), null), + "不声明 ⇒ 最终下发空串(前端据此摘掉「图片」按钮)"); + } + /** 按生产代码的口径算出「最终下发给前端的上传地址」 */ private String emittedUploadPath(ToolDef def, ToolParam fileParam) { return AgentOrchestrator.resolveUploadPath(def.getModule(), @@ -432,9 +541,15 @@ class ModuleFormParityTest { assertEquals(Arrays.asList("公开", "不公开"), ifOpen.getEnumLabels()); assertEquals("1", ifOpen.getDefaultValue()); - // 正文:多行文本,占位文案与模块页的富文本编辑器一致 - assertEquals("textarea", param(def, "remark").getWidget()); - assertEquals("请输入正文内容…", param(def, "remark").getPlaceholderText()); + // 正文:**富文本**(模块页 RecordEdit.vue 用 wangEditor),占位文案与模块页一致。 + // ⚠️ 该模块的编辑器没配 uploadImage(模块 file 域只有 Upload/Preview/Down) ⇒ + // 工具**不声明** .richText(...),下发的上传地址应为 null,前端据此把工具栏的 + // 「图片」整组摘掉(模块页就是这个形态)。 + ToolParam remark = param(def, "remark"); + assertEquals("richtext", remark.getWidget(), "正文用富文本编辑器(模块页 wangEditor)"); + assertNull(remark.getRichTextUploadOverride(), + "该模块不支持正文插图,不能声明上传地址(声明了就会出现模块页没有的图片按钮)"); + assertEquals("请输入正文内容…", remark.getPlaceholderText()); // 附件:上传接口与其余模块**不同**(file 域,不是 /api/base/FileUpload)。 // 注意这里断言的是「模块内相对路径」的声明,最终下发值(带 /profession 前缀)由