feat(表单): 五个模块正文换成模块页同款富文本,公告/宿舍的接收对象换成自绘控件
build / build-push (push) Successful in 1m15s

彻底对齐各模块「新建 / 发布」页里的两类控件(此前只有通知公告搬到位):

正文 → wangEditor(widget=richtext)
  · 校园公告 content、宿舍通知 content(发布 + 编辑)、家校活动 content(发布 + 编辑)、
    作业 description
  · 教师专业发展 remark:只声明形态、**不声明** .richText(...) —— 该模块 file 域只有
    Upload/Preview/Down(没有 UploadImage),模块页 RecordEdit.vue 也没配 uploadImage,
    所以下发空地址,前端据此把工具栏的「图片」组整组摘掉(模块页就是这个形态)

接收对象 → 自绘控件(widget=scopepicker / roompicker)
  · 校园公告 pushScopes:换成 ClassScopeInput 同款(全部班级 / 指定班级 + 弹窗班级树);
    提交口径不变,仍是 [{scopeType,scopeId,scopeName}]
  · 宿舍通知 receiveList:换成模块页「选择接收宿舍」弹窗同款(左楼栋 / 右按楼层分组的房间);
    v-model 是字符串(房间 id 逗号串或 ALL 哨兵),与模块 receiveList 的真实契约一致

基建
  · ToolParam 新增 richText(upload,preview) / scopePicker(type) / roomPicker() /
    pickerSource(页签名, 模块内相对路径);对应 FormField 四个字段
  · AgentOrchestrator:新增 resolveModulePath 通用形态,richtext 与 pickerSources 的地址
    一律只写模块内相对路径、前缀在这里拼;richtext **没有兜底**(不声明就是空)
  · 带 optionCall 的字段会被改写成 select 的白名单改用 isSelfDrawnPicker()
    (原先是硬编码 userpicker),并放开为 public 供契约测试复用生产口径

测试(139 tests, 0 failures)
  · ModuleFormParityTest 新增两条通用不变量:正文图片地址与弹窗数据源都必须带模块前缀、
    不许自己带前缀、不许双斜杠;另加「专业发展不得拿到兜底上传地址」
  · FormFieldWidgetTest 新增 richtext(含「不声明 = 空」反向断言)与自绘控件透传两条
  · IntentDirectFormTest / DormActivityPublishIntentTest 同步改成新控件口径
This commit is contained in:
lijing
2026-10-14 16:56:07 +08:00
parent c084c978aa
commit 006003c842
11 changed files with 411 additions and 39 deletions
@@ -720,6 +720,31 @@ public class AgentOrchestrator {
return prefix + path; return prefix + path;
} }
/**
* 是否是「自绘选择控件」——这些控件的选项**不是**下拉字典,而是弹窗里的一棵树/一张表,
* 所以既不能被 optionCall 的降级逻辑改写成 select,也不走下拉的拍平。
*
* <p>{@code userpicker} 通知对象(组织树)· {@code scopepicker} 接收班级(年级→班级树)
* · {@code roompicker} 接收宿舍(楼栋→房间)。
*
* <p>{@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<String, Object> args, List<ToolParam> missing, private FormRequest buildForm(ToolDef def, Map<String, Object> args, List<ToolParam> missing,
CallerContext caller) { CallerContext caller) {
@@ -759,15 +784,21 @@ public class AgentOrchestrator {
} }
// 富文本正文:正文里「插入图片」用的是**另一个**接口(UploadImage / Preview), // 富文本正文:正文里「插入图片」用的是**另一个**接口(UploadImage / Preview),
// 与附件的 FileUpload 不是一回事,所以单独下发两条(同样只写相对路径,前缀在这里拼)。 // 与附件的 FileUpload 不是一回事,所以单独下发两条(同样只写相对路径,前缀在这里拼)。
//
// ★ 没有 `relFallback`:正文图片上传地址**必须由工具显式声明**(`.richText(upload, preview)`),
// 不声明就是**空**。若沿用附件的 `/api/base/FileUpload` 兜底,就会给「模块里根本没有
// 这个接口」的富文本(专业发展:file 域只有 Upload/Preview/Down)下发一个错地址,
// 用户点了插入图片只会拿到 404。空地址时前端把工具栏的「图片」组去掉。
if ("richtext".equals(f.getWidget())) { if ("richtext".equals(f.getWidget())) {
String baseUrl = settings.getModules().get(def.getModule()); String baseUrl = settings.getModules().get(def.getModule());
f.setRichTextUploadPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextUploadOverride())); f.setRichTextUploadPath(resolveModulePath(def.getModule(), baseUrl, p.getRichTextUploadOverride(), null));
f.setRichTextPreviewPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextPreviewOverride())); f.setRichTextPreviewPath(resolveModulePath(def.getModule(), baseUrl, p.getRichTextPreviewOverride(), null));
} }
// 「通知对象」自绘控件(userpicker)的选人弹窗数据源:模块页有两个页签 // 自绘选择控件(userpicker / scopepicker / roompicker)的**数据源**:
// (基础平台组织树 / 自定义组),optionCall 只能描述一个地址,其余由这里补齐。 // 这些控件的弹窗要的是**原始嵌套结构**(组织树 / 班级树 / 楼栋·房间),
// optionCall 只能描述一个地址、且会被当字典拍平,所以另用 pickerSources 下发。
// 同样只写相对路径,前缀在这里拼(见 resolveModulePath)。 // 同样只写相对路径,前缀在这里拼(见 resolveModulePath)。
if ("userpicker".equals(f.getWidget()) && p.getPickerSources() != null) { if (isSelfDrawnPicker(f.getWidget()) && p.getPickerSources() != null) {
String baseUrl = settings.getModules().get(def.getModule()); String baseUrl = settings.getModules().get(def.getModule());
List<FormField.PickerSource> srcs = new ArrayList<>(); List<FormField.PickerSource> srcs = new ArrayList<>();
for (String[] ps : p.getPickerSources()) { for (String[] ps : p.getPickerSources()) {
@@ -776,7 +807,7 @@ public class AgentOrchestrator {
} }
BrowserToolRequest.BrowserCall call = new BrowserToolRequest.BrowserCall(); BrowserToolRequest.BrowserCall call = new BrowserToolRequest.BrowserCall();
call.setName("picker." + ps[0]); call.setName("picker." + ps[0]);
call.setTitle("选择用户 · " + ps[0]); call.setTitle(pickerTitle(f.getWidget()) + " · " + ps[0]);
call.setWrite(false); call.setWrite(false);
call.setMethod("GET"); call.setMethod("GET");
call.setPathAndQuery(resolveModulePath(def.getModule(), baseUrl, ps[1], null)); call.setPathAndQuery(resolveModulePath(def.getModule(), baseUrl, ps[1], null));
@@ -802,11 +833,11 @@ public class AgentOrchestrator {
// ★ 多值/自绘控件必须保留原样: // ★ 多值/自绘控件必须保留原样:
// multiselect(接收班级/宿舍)、scopes(公告推送范围)、flow(公告审批流) // multiselect(接收班级/宿舍)、scopes(公告推送范围)、flow(公告审批流)
// 都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力; // 都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力;
// userpicker(通知公告的「通知对象」)是**自绘控件**(radio 全部/指定 + 选人弹窗), // userpicker / scopepicker / roompicker 是**自绘控件**(radio 全部/指定 + 弹窗树),
// 它自己会用 optionCall 去取组织树 —— 降级成 select 就等于把它改回普通下拉。 // 它们自己会用 pickerSources 去取原始结构 —— 降级成 select 就等于把它们改回普通下拉。
String w = f.getWidget(); 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)) { && !isSelfDrawnPicker(w)) {
f.setWidget("select"); f.setWidget("select");
} }
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖 // 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
@@ -818,7 +849,7 @@ public class AgentOrchestrator {
f.setOptions(opts); f.setOptions(opts);
String w2 = f.getWidget(); 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)) { && !isSelfDrawnPicker(w2)) {
f.setWidget("select"); f.setWidget("select");
} }
f.setOptionsFallback(false); f.setOptionsFallback(false);
@@ -369,6 +369,39 @@ public class ToolParam {
return this; return this;
} }
/**
* 声明为「班级范围」自绘控件(widget=scopepicker)。
*
* <p>与 {@link #scopes(int)} <b>提交口径完全相同</b>({@code [{scopeType,scopeId,scopeName}]}),
* 区别只在**呈现**:这个控件是校园公告模块页 {@code ClassScopeInput} 的同款
* —— radio「全部班级 / 指定班级」+ 只读回显 + 弹窗班级树(左树右已选)。
* {@code scopes} 是普通多选下拉,模块页早就不长那样了。
*
* <p>弹窗的树由 {@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)。
*
* <p>宿舍通知模块页那个自写的「选择接收宿舍」弹窗(左楼栋列表 / 右按楼层分组的房间勾选 /
* 底部已选间数与住宿生人数),提交房间 id 逗号串,或 {@link #allOption} 的全选哨兵。
*
* <p>弹窗要**两个**数据源,按顺序用 {@link #pickerSource} 下发:
* ① 楼栋列表;② 房间列表(地址里可带 {@code {buildingId}} 占位,前端切楼栋时替换后再请求)。
*/
public ToolParam roomPicker() {
this.widget = "roompicker";
return this;
}
/** /**
* 声明为「审批流」控件(校园公告的 {@code flow})。 * 声明为「审批流」控件(校园公告的 {@code flow})。
* *
@@ -22,6 +22,12 @@ public final class ActivityTools {
/** 活动分类字典的 type 值(/api/dict/List?type=2) */ /** 活动分类字典的 type 值(/api/dict/List?type=2) */
private static final Object DICT_TYPE_CATEGORY = 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() { private ActivityTools() {
} }
@@ -110,8 +116,10 @@ public final class ActivityTools {
"POST_JSON", "/api/activity/Create") "POST_JSON", "/api/activity/Create")
.p(ToolParam.required("title", "string", "活动标题(50 字以内)") .p(ToolParam.required("title", "string", "活动标题(50 字以内)")
.label("活动标题").eg("秋季亲子运动会"), .label("活动标题").eg("秋季亲子运动会"),
ToolParam.required("content", "string", "活动说明(纯文本,对应模块页的富文本编辑器)") ToolParam.required("content", "string", "活动说明(富文本 HTML,对应模块页的 wangEditor)")
.label("活动说明").as("textarea").eg("为增进家校互动,学校拟举办秋季亲子运动会……"), .label("活动说明").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)
.eg("为增进家校互动,学校拟举办秋季亲子运动会……"),
ToolParam.required("category", "string", "活动分类:**分类名称**(模块页存的是名称,不是 id)") ToolParam.required("category", "string", "活动分类:**分类名称**(模块页存的是名称,不是 id)")
.label("活动分类") .label("活动分类")
.optionsFrom("activity.categoryDic").optionsKeys("v", "v"), .optionsFrom("activity.categoryDic").optionsKeys("v", "v"),
@@ -168,8 +176,9 @@ public final class ActivityTools {
.label("活动"), .label("活动"),
ToolParam.required("title", "string", "活动标题") ToolParam.required("title", "string", "活动标题")
.label("活动标题"), .label("活动标题"),
ToolParam.required("content", "string", "活动说明") ToolParam.required("content", "string", "活动说明(富文本 HTML)")
.label("活动说明").as("textarea")) .label("活动说明").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH))
.fix("receiveType", "class") .fix("receiveType", "class")
.fix("receiveList", "ALL") .fix("receiveList", "ALL")
.write("/list")); .write("/list"));
@@ -13,6 +13,22 @@ public final class CampusNoticeTools {
public static final String MODULE = "campusnotice"; 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 <img src>} 显示不出来。 */
private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}";
/**
* 「接收班级」弹窗的数据源。
*
* <p>逐项对齐模块页 {@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() { private CampusNoticeTools() {
} }
@@ -148,8 +164,10 @@ public final class CampusNoticeTools {
List<ToolParam> ps = new ArrayList<>(); List<ToolParam> ps = new ArrayList<>();
ps.add(ToolParam.required("title", "string", "公告标题(200 字以内)") ps.add(ToolParam.required("title", "string", "公告标题(200 字以内)")
.label("公告标题").eg("关于国庆假期安排的通知")); .label("公告标题").eg("关于国庆假期安排的通知"));
ps.add(ToolParam.required("content", "string", "公告正文(纯文本,对应模块页的富文本编辑器)") ps.add(ToolParam.required("content", "string", "公告正文(富文本 HTML,对应模块页的 wangEditor)")
.label("公告正文").as("textarea").eg("各位老师、同学:经学校研究决定……")); .label("公告正文").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)
.eg("各位老师、同学:经学校研究决定……"));
ps.add(ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要") ps.add(ToolParam.optional("type", "integer", "重要程度:1 普通 / 2 重要")
.label("重要程度").dft("1") .label("重要程度").dft("1")
.options(new String[]{"1", "2"}, new String[]{"普通", "重要"})); .options(new String[]{"1", "2"}, new String[]{"普通", "重要"}));
@@ -160,10 +178,13 @@ public final class CampusNoticeTools {
ps.add(ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)") ps.add(ToolParam.optional("deptName", "string", "发文部门名称(由所选部门自动带出,无需填写)")
.label("发文部门名称") .label("发文部门名称")
.mirrorLabelOf("deptId")); .mirrorLabelOf("deptId"));
// ★ 接收班级 = 模块页 ClassScopeInput 的同款自绘控件(radio 全部/指定 + 弹窗班级树),
// 提交口径不变:仍拼成 [{scopeType,scopeId,scopeName}],只是不再用普通多选下拉。
ps.add(ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级") ps.add(ToolParam.optional("pushScopes", "array", "接收班级:留空或含全部 = 全部班级;也可多选具体班级")
.label("接收班级") .label("接收班级")
.optionsFrom("notice.stuTree").optionsKeys("id", "name") .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", "是否置顶") ps.add(ToolParam.optional("topmost", "integer", "是否置顶")
.label("置顶").dft("0") .label("置顶").dft("0")
.options(new String[]{"0", "1"}, new String[]{"否", "是"})); .options(new String[]{"0", "1"}, new String[]{"否", "是"}));
@@ -29,6 +29,26 @@ public final class DormNoticeTools {
/** 发文部门字典的 type 值(/api/dict/List?type=dept) */ /** 发文部门字典的 type 值(/api/dict/List?type=dept) */
private static final String DICT_TYPE_DEPT = "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()} 一一对应):
* <ol>
* <li>楼栋列表 —— {@code dormApi.getBuildingList()}(含房间数/床位数/入住数/宿管);</li>
* <li>房间列表 —— {@code dormApi.getRoomList(buildingId)}(**buildingId 可缺省**:
* 模块页回显时就是不带参数调的,一次取回全部房间用于补全箱名/床位数)。
* 前端切楼栋时自行追加 {@code ?buildingId=},所以这里只给裸地址。</li>
* </ol>
* 同样只写模块内相对路径,前缀由编排层拼。
*/
private static final String[] PICKER_SOURCE_BUILDING = {"楼栋", "/api/dorm/BuildingList"};
private static final String[] PICKER_SOURCE_ROOM = {"房间", "/api/dorm/RoomList"};
private DormNoticeTools() { private DormNoticeTools() {
} }
@@ -105,8 +125,10 @@ public final class DormNoticeTools {
"POST_JSON", "/api/notice/Create") "POST_JSON", "/api/notice/Create")
.p(ToolParam.required("title", "string", "通知标题(50 字以内)") .p(ToolParam.required("title", "string", "通知标题(50 字以内)")
.label("通知标题").eg("关于宿舍熄灯时间调整的通知"), .label("通知标题").eg("关于宿舍熄灯时间调整的通知"),
ToolParam.required("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)") ToolParam.required("content", "string", "通知正文(富文本 HTML,对应模块页的 wangEditor)")
.label("通知正文").as("textarea").eg("各位家长:自本周起,宿舍熄灯时间调整为 22:30……"), .label("通知正文").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)
.eg("各位家长:自本周起,宿舍熄灯时间调整为 22:30……"),
ToolParam.required("noticeType", "string", "通知类型,来自 dorm.noticeTypeDic 的 k 字段") ToolParam.required("noticeType", "string", "通知类型,来自 dorm.noticeTypeDic 的 k 字段")
.label("通知类型") .label("通知类型")
.optionsFrom("dorm.noticeTypeDic").optionsKeys("k", "v"), .optionsFrom("dorm.noticeTypeDic").optionsKeys("k", "v"),
@@ -132,9 +154,15 @@ public final class DormNoticeTools {
ToolParam.optional("formType", "string", "回执单据类型(receiptMode=fill 时必填),来自 dorm.formTemplates 的 type 字段") ToolParam.optional("formType", "string", "回执单据类型(receiptMode=fill 时必填),来自 dorm.formTemplates 的 type 字段")
.label("回执单据") .label("回执单据")
.optionsFrom("dorm.formTemplates").optionsKeys("type", "name"), .optionsFrom("dorm.formTemplates").optionsKeys("type", "name"),
// ★ 接收宿舍 = 模块页那个自写的「选择接收宿舍」弹窗同款控件
// (左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数)。
// 提交口径不变:房间 id 逗号串,或全选哨兵 "ALL"。
ToolParam.optional("receiveList", "array", "接收宿舍:房间 id 列表;留空或含 ALL = 全部宿舍") ToolParam.optional("receiveList", "array", "接收宿舍:房间 id 列表;留空或含 ALL = 全部宿舍")
.label("接收宿舍") .label("接收宿舍")
.optionsFrom("dorm.rooms").optionsKeys("roomId", "buildingName+roomNo") .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", "全部宿舍"), .join(",").allOption("ALL", "全部宿舍"),
ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅需回执时有意义)") ToolParam.optional("needSupervisor", "integer", "是否需要宿管到场配合执行(仅需回执时有意义)")
.label("宿管到场配合").dft("0") .label("宿管到场配合").dft("0")
@@ -164,8 +192,9 @@ public final class DormNoticeTools {
.label("通知"), .label("通知"),
ToolParam.required("title", "string", "通知标题") ToolParam.required("title", "string", "通知标题")
.label("通知标题"), .label("通知标题"),
ToolParam.required("content", "string", "通知正文") ToolParam.required("content", "string", "通知正文(富文本 HTML)")
.label("通知正文").as("textarea")) .label("通知正文").as("richtext")
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH))
.fix("receiveType", "room") .fix("receiveType", "room")
.fix("receiveList", "ALL") .fix("receiveList", "ALL")
.write("/list")); .write("/list"));
@@ -34,6 +34,13 @@ public final class HomeworkTools {
*/ */
private static final String UPLOAD_PATH = "/api/file/Upload"; 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() { private HomeworkTools() {
} }
@@ -159,8 +166,10 @@ public final class HomeworkTools {
ps.add(ToolParam.required("classIds", "array", "接收班级:班级 id 列表(仅本人任教班级,可多选)") ps.add(ToolParam.required("classIds", "array", "接收班级:班级 id 列表(仅本人任教班级,可多选)")
.label("接收班级") .label("接收班级")
.optionsFrom("homework.classTree").optionsKeys("id", "name")); .optionsFrom("homework.classTree").optionsKeys("id", "name"));
ps.add(ToolParam.optional("description", "string", "作业内容(纯文本,对应模块页的富文本编辑器)") ps.add(ToolParam.optional("description", "string", "作业内容(富文本 HTML,对应模块页的 wangEditor)")
.label("作业内容").as("textarea").eg("完成课本 P32 第 1~5 题,写出解题过程。")); .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(留空=不设截止)") ps.add(ToolParam.optional("deadlineAt", "string", "截止时间 yyyy-MM-dd HH:mm:ss(留空=不设截止)")
.label("截止时间").eg("2026-10-20 22:00:00")); .label("截止时间").eg("2026-10-20 22:00:00"));
ps.add(ToolParam.optional("submitType", "array", ps.add(ToolParam.optional("submitType", "array",
@@ -182,8 +182,13 @@ public final class ProfessionTools {
ps.add(ToolParam.optional("ifOpen", "integer", "是否公开:1 公开 / 0 不公开") ps.add(ToolParam.optional("ifOpen", "integer", "是否公开:1 公开 / 0 不公开")
.label("是否公开").as("radio").dft("1") .label("是否公开").as("radio").dft("1")
.options(new String[]{"1", "0"}, new String[]{"公开", "不公开"})); .options(new String[]{"1", "0"}, new String[]{"公开", "不公开"}));
ps.add(ToolParam.optional("remark", "string", "正文内容(对应模块页的富文本编辑器)") // ★ 富文本但**不带图片上传**:模块页 RecordEdit.vue 的 editorConfig 只有 placeholder
.label("正文内容").as("textarea").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 数组,由表单自动生成)") ps.add(ToolParam.optional("contents", "string", "栏目动态字段的取值(JSON 数组,由表单自动生成)")
.label("栏目动态字段").hide()); .label("栏目动态字段").hide());
@@ -30,8 +30,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* 本轮起「发通知 / 发布通知」这类不带限定的说法<b>不再直达</b> —— 校园公告与宿舍通知 * 本轮起「发通知 / 发布通知」这类不带限定的说法<b>不再直达</b> —— 校园公告与宿舍通知
* 都有发布工具,同一句「发通知」是跨模块歧义(猜错模块比不直达更糟),交给模型按上下文选。</li> * 都有发布工具,同一句「发通知」是跨模块歧义(猜错模块比不直达更糟),交给模型按上下文选。</li>
* <li><b>仅教职工</b>:两个发布工具都归 STAFF_ONLY,学生身份没有落点 → 落回模型如实说明。</li> * <li><b>仅教职工</b>:两个发布工具都归 STAFF_ONLY,学生身份没有落点 → 落回模型如实说明。</li>
* <li><b>接收对象固定为「全部」</b>:{@code receiveType}/{@code receiveList} 由 {@code fix} 写死, * <li><b>接收对象的「级别」固定,具体对象不固定</b>:{@code receiveType} 由 {@code fix} 写死
* 房间/班级级定向请到模块页面里勾选,避免表单里出现难用的 id 输入框。</li> * (宿舍=房间级 / 活动=班级级);{@code receiveList} 在表单里选、默认「全部」。
* <br>★ 2026-10-14:宿舍的接收对象不再是通用多选下拉,而是搬了模块页那块自写的
* 「选择接收宿舍」弹窗({@code widget=roompicker},左楼栋 / 右按楼层分组的房间)——
* 早先「只给全部、定向去模块页勾」的做法已作废,用户不必再跳出去。</li>
* </ol> * </ol>
*/ */
class DormActivityPublishIntentTest { class DormActivityPublishIntentTest {
@@ -123,13 +126,26 @@ class DormActivityPublishIntentTest {
assertTrue(required.contains("noticeType"), "通知类型是发布表单必填项"); assertTrue(required.contains("noticeType"), "通知类型是发布表单必填项");
assertTrue(required.contains("deptId"), "发文部门是发布表单必填项"); assertTrue(required.contains("deptId"), "发文部门是发布表单必填项");
// 接收对象:receiveType 固定为「房间」级;receiveList 由表单多选(默认「全部宿舍」) // 接收对象:receiveType 固定为「房间」级;receiveList 由表单选择(默认「全部宿舍」)。
// ★ 2026-10-14:控件换成宿舍模块页那块自写的「选择接收宿舍」弹窗同款(widget=roompicker)。
// 取值契约**不变**(房间 id 逗号串 / ALL 哨兵),所以下面几条断言照旧。
assertEquals("room", publish.getFixed().get("receiveType")); assertEquals("room", publish.getFixed().get("receiveType"));
ToolParam receiveList = param(publish, "receiveList"); ToolParam receiveList = param(publish, "receiveList");
assertNotNull(receiveList, "接收宿舍应作为可选字段出现在表单里(不再写死)"); assertNotNull(receiveList, "接收宿舍应作为可选字段出现在表单里(不再写死)");
assertEquals("roompicker", receiveList.resolveWidget(),
"接收宿舍用模块页同款房间弹窗(左楼栋 / 右按楼层分组的房间勾选),不再是通用多选下拉");
assertEquals("ALL", receiveList.getAllToken(), "「全部宿舍」哨兵是 ALL"); assertEquals("ALL", receiveList.getAllToken(), "「全部宿舍」哨兵是 ALL");
assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串"); assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串");
assertEquals("dorm.rooms", receiveList.getOptionsFrom()); assertEquals("dorm.rooms", receiveList.getOptionsFrom());
// 弹窗要**两个**数据源(主从,不是页签):① 楼栋列表 ② 房间列表。
// 与附件上传同一个坑:只写模块内相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。
List<String[]> 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(), "发布表单应渲染全部字段(与模块「新建通知」页一致)"); assertTrue(publish.isFormAll(), "发布表单应渲染全部字段(与模块「新建通知」页一致)");
} }
@@ -230,11 +246,23 @@ class DormActivityPublishIntentTest {
"smsFlag", "receiptFlag", "fileIds", "timingSendTime", "publishType")) { "smsFlag", "receiptFlag", "fileIds", "timingSendTime", "publishType")) {
assertTrue(names.contains(k), "公告发布表单缺少字段(模块页有):" + k); 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"); 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(Integer.valueOf(4), scopes.getScopeType(), "班级节点 scopeType=4");
assertEquals("ALL", scopes.getAllToken()); assertEquals("ALL", scopes.getAllToken());
// 弹窗的树只有**一个**数据源(scopepicker 是单页签,与 userpicker 的多页签不同):
// 校园公告模块没有 GetClassTree,走 /api/base/getStuTree?toUser=false,
// 前端自底向上归成「年级→班级」两层。同样只写相对路径,前缀由编排层拼。
List<String[]> 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 @Test
@@ -183,4 +183,82 @@ class FormFieldWidgetTest {
assertEquals(4, f.getRows().intValue()); assertEquals(4, f.getRows().intValue());
assertEquals(1000, f.getMaxLength().intValue()); assertEquals(1000, f.getMaxLength().intValue());
} }
/**
* ⑪ 富文本正文:控件形态 + **插图地址必须原样下发**,且「不声明 = 空」。
*
* <p>两个反向断言都是踩过的坑:
* <ul>
* <li>地址**没有兜底** —— 像专业发展那种模块编辑器压根没配 uploadImage,
* 工具里不写 {@code .richText(...)} 就不能凭空给一个默认值,
* 否则前端会长出模块页没有的「图片」按钮;</li>
* <li>{@code .as("richtext")} 与 {@code .richText(...)} 是两件事:前者管形态,
* 后者管地址。只写前者(专业发展的写法)地址必须是 null。</li>
* </ul>
*/
@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 的页签名与地址一一对应。
*
* <p>⚠️ 这里只断言**工具层声明**({@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());
}
} }
@@ -190,9 +190,14 @@ class IntentDirectFormTest {
assertEquals("/list/new", resp.getForm().getLandingPath()); assertEquals("/list/new", resp.getForm().getLandingPath());
assertTrue(resp.getReply().contains("发布"), "引导语应说明这是发布,实际:" + resp.getReply()); assertTrue(resp.getReply().contains("发布"), "引导语应说明这是发布,实际:" + resp.getReply());
// 公告表单:标题(单行)+ 正文(多行)+ 发文部门(下拉) // 公告表单:标题(单行)+ 正文(富文本,与模块页 NewNotice.vue 的 wangEditor 一致)+ 发文部门(下拉)
assertEquals("text", field(resp, "title").getWidget()); 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"); FormField deptId = field(resp, "deptId");
assertEquals("select", deptId.getWidget(), "发文部门应是下拉,不该让用户手填部门编号"); assertEquals("select", deptId.getWidget(), "发文部门应是下拉,不该让用户手填部门编号");
@@ -300,9 +305,13 @@ class IntentDirectFormTest {
assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认"); assertTrue(resp.getForm().isWrite(), "是写操作,前端要二次确认");
assertEquals("/list/new", resp.getForm().getLandingPath()); assertEquals("/list/new", resp.getForm().getLandingPath());
// 标题 + 正文 + 通知类型(下拉)+ 发文部门(下拉) // 标题 + 正文(富文本,模块页 NewNotice.vue 用 wangEditor)+ 通知类型(下拉)+ 发文部门(下拉)
assertEquals("text", field(resp, "title").getWidget()); 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"); FormField noticeType = field(resp, "noticeType");
assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号"); assertEquals("select", noticeType.getWidget(), "通知类型应是下拉,不该让用户手填编号");
@@ -336,9 +345,14 @@ class IntentDirectFormTest {
assertTrue(resp.getForm().isWrite()); assertTrue(resp.getForm().isWrite());
assertEquals("/list/new", resp.getForm().getLandingPath()); assertEquals("/list/new", resp.getForm().getLandingPath());
// 活动表单:标题 + 说明(多行)+ 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间 // 活动表单:标题 + 说明(**富文本**,模块页 PublishActivity.vue 用 wangEditor)+
// 分类(下拉)+ 主办部门(下拉)+ 地点 + 起止时间
assertEquals("text", field(resp, "title").getWidget()); 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"); FormField category = field(resp, "category");
assertEquals("select", category.getWidget()); assertEquals("select", category.getWidget());
@@ -8,6 +8,7 @@ import com.yunhai.agent.tool.ToolDef;
import com.yunhai.agent.tool.ToolExecutor; import com.yunhai.agent.tool.ToolExecutor;
import com.yunhai.agent.tool.ToolParam; import com.yunhai.agent.tool.ToolParam;
import com.yunhai.agent.tool.ToolRegistry; import com.yunhai.agent.tool.ToolRegistry;
import org.apache.commons.lang3.StringUtils;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
@@ -340,6 +341,114 @@ class ModuleFormParityTest {
assertTrue(checked >= 6, "六个模块的附件字段都应被覆盖到,实际只检查了 " + checked + " 个"); assertTrue(checked >= 6, "六个模块的附件字段都应被覆盖到,实际只检查了 " + checked + " 个");
} }
/**
* 同一条不变量的两张新面孔:**正文图片地址**与**自绘控件的弹窗数据源**。
*
* <p>它们和附件上传是同一个坑的三种形态(只写模块内相对路径、前缀由编排层拼),
* 早先的通用不变量只遍历 {@code widget=file} 的字段,于是这两类新增地址
* 天然落在覆盖面之外 —— 一旦有人写成绝对路径或漏声明,测试是绿的、线上 404。
*
* <p>判据(三类共用):① 工具里写的必须是模块内相对路径(不带空前缀);
* ② 拼完前缀后必须以空前缀开头;③ 不能出现双斜杠。
* ④ 另外钉住「正文图片地址没有兜底」——不声明就是空,不能凭空变成附件的 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(...)},下发的上传地址必须是**空**,
* 前端据此把工具栏的「图片」整组摘掉。
*
* <p>若哪天有人「好心」给它加一个 /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) { private String emittedUploadPath(ToolDef def, ToolParam fileParam) {
return AgentOrchestrator.resolveUploadPath(def.getModule(), return AgentOrchestrator.resolveUploadPath(def.getModule(),
@@ -432,9 +541,15 @@ class ModuleFormParityTest {
assertEquals(Arrays.asList("公开", "不公开"), ifOpen.getEnumLabels()); assertEquals(Arrays.asList("公开", "不公开"), ifOpen.getEnumLabels());
assertEquals("1", ifOpen.getDefaultValue()); assertEquals("1", ifOpen.getDefaultValue());
// 正文:多行文本,占位文案与模块页的富文本编辑器一致 // 正文:**富文本**(模块页 RecordEdit.vue 用 wangEditor),占位文案与模块页一致。
assertEquals("textarea", param(def, "remark").getWidget()); // ⚠️ 该模块的编辑器没配 uploadImage(模块 file 域只有 Upload/Preview/Down) ⇒
assertEquals("请输入正文内容…", param(def, "remark").getPlaceholderText()); // 工具**不声明** .richText(...),下发的上传地址应为 null,前端据此把工具栏的
// 「图片」整组摘掉(模块页就是这个形态)。
ToolParam remark = param(def, "remark");
assertEquals("richtext", remark.getWidget(), "正文用富文本编辑器(模块页 wangEditor)");
assertNull(remark.getRichTextUploadOverride(),
"该模块不支持正文插图,不能声明上传地址(声明了就会出现模块页没有的图片按钮)");
assertEquals("请输入正文内容…", remark.getPlaceholderText());
// 附件:上传接口与其余模块**不同**(file 域,不是 /api/base/FileUpload)。 // 附件:上传接口与其余模块**不同**(file 域,不是 /api/base/FileUpload)。
// 注意这里断言的是「模块内相对路径」的声明,最终下发值(带 /profession 前缀)由 // 注意这里断言的是「模块内相对路径」的声明,最终下发值(带 /profession 前缀)由