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 与 {@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 逐项对齐模块页 {@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 两个反向断言都是踩过的坑:
+ * ⚠️ 这里只断言**工具层声明**({@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 前缀)由
} 显示不出来。 */
+ private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}";
+
+ /**
+ * 「接收班级」弹窗的数据源。
+ *
+ *
+ *
+ * 同样只写模块内相对路径,前缀由编排层拼。
+ */
+ 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;
* 本轮起「发通知 / 发布通知」这类不带限定的说法不再直达 —— 校园公告与宿舍通知
* 都有发布工具,同一句「发通知」是跨模块歧义(猜错模块比不直达更糟),交给模型按上下文选。
*
★ 2026-10-14:宿舍的接收对象不再是通用多选下拉,而是搬了模块页那块自写的
+ * 「选择接收宿舍」弹窗({@code widget=roompicker},左楼栋 / 右按楼层分组的房间)——
+ * 早先「只给全部、定向去模块页勾」的做法已作废,用户不必再跳出去。
+ *
+ */
+ @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 的页签名与地址一一对应。
+ *
+ *