feat(通知公告): 发布表单支持模块页的富文本正文与选人控件
build / build-push (push) Successful in 2m1s

前端已把 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。
This commit is contained in:
lijing
2026-10-14 15:19:24 +08:00
parent 8f0f8a04b3
commit c084c978aa
7 changed files with 273 additions and 30 deletions
@@ -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}。
* <p>与附件同理:模块的上传接口只认浏览器 CAS 会话,必须由前端带用户登录态 POST(multipart)。
*/
private String richTextUploadPath;
/**
* {@code widget=richtext} 时,正文内图片的预览地址模板(**已带模块前缀**,含 {@code {id}} 占位),
* 如 {@code /notice/api/file/Preview?id={id}} —— 前端拿到上传返回的文件 id 后替换占位符,
* 得到可直接放进 {@code <img src>} 的地址。
*/
private String richTextPreviewPath;
/**
* {@code widget=userpicker}(通知对象)的**数据源页签**,形如
* {@code [{name:"基础平台", call:{...}}, {name:"自定义组", call:{...}}]}。
*
* <p>搬运自通知公告模块页 {@code components/UserCheckModal.vue} 的两个页签,
* 每个 {@code call} 都已经是「**带模块前缀**的浏览器代取调用」(同 {@link #optionCall} 的形态),
* 由前端用用户登录态去取原始**嵌套树**(不做拍平 —— 拍平会丢掉层级,树就画不出来)。
*/
private List<PickerSource> pickerSources;
/** {@code widget=userpicker} 的一个数据源页签:页签名 + 浏览器代取调用 */
@lombok.Data
public static class PickerSource {
private String name;
private com.yunhai.agent.model.BrowserToolRequest.BrowserCall call;
}
/**
* 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。
* <p>前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]};
@@ -129,6 +129,36 @@ public class ToolParam {
*/
private String uploadPathOverride;
/**
* {@code widget=richtext} 时,正文编辑器里「插入图片」用的上传地址(**模块内相对路径**)。
*
* <p>与 {@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}} 占位)。
*
* <p>模块页把图片 src 写成 {@code /api/file/Preview?id=<fileid>}(inline 输出图片,
* 而 {@code /api/file/Down} 是 attachment,不能内嵌)。
*/
private String richTextPreviewOverride;
/**
* {@code widget=userpicker} 的**数据源页签**:每项是 {@code {页签名, 模块内相对地址}}。
*
* <p>通知公告模块页的选人弹窗有两个页签 —— 「基础平台」({@code /api/base/GetSchoolAll} 组织树)
* 与「自定义组」({@code /api/contacts/GetGroupTree?type=1})。要 1:1 搬运控件,两个页签都得给。
* {@code optionCall} 只能描述**一个**地址,所以额外用这里下发。
*
* <p>★ 与 {@link #uploadPathOverride} 同约定:只写模块内相对路径(可带 query),
* 前缀一律由编排层拼 —— 自己带前缀就是线上 404 的老坑。
*/
private List<String[]> pickerSources;
/** 是否在表单里隐藏(如由系统固定填入的参数,用户无需看见) */
private boolean hidden;
@@ -476,6 +506,38 @@ public class ToolParam {
return this;
}
/**
* {@code widget=richtext}(富文本正文):声明「插入图片」的上传地址与预览地址模板。
*
* <p>两者都只写**模块内相对路径**(前缀由编排层拼,与 {@link #upload} 同约定)。
* 例:{@code .richText("/api/file/UploadImage", "/api/file/Preview?id={id}")}
*
* <p>★ 为什么必须成对下发:模块的图片上传接口回的是 {@code data[0].id}(文件服务 id),
* 而能内嵌到正文里的地址是另一个接口({@code /api/file/Preview},inline)。
* 前端拿到 id 后要按模板拼出可直接放进 {@code <img src>} 的地址。
*/
public ToolParam richText(String uploadPath, String previewPath) {
this.richTextUploadOverride = uploadPath;
this.richTextPreviewOverride = previewPath;
return this;
}
/**
* {@code widget=userpicker}(通知对象):追加一个「选人弹窗」的数据源页签。
*
* <p>与 {@link #richText} / {@link #upload} 同约定:{@code pathAndQuery} 只写**模块内相对路径**
* (可带 query,如 {@code /api/contacts/GetGroupTree?type=1}),前缀由编排层拼。
*
* <p>调用顺序即页签顺序 —— 通知公告模块页是「基础平台」在前、「自定义组」在后。
*/
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;
@@ -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 <img src>} 显示不出来。 */
private static final String RICHTEXT_PREVIEW_PATH = "/api/file/Preview?id={id}";
/**
* 「通知对象」选人弹窗的两个数据源页签(模块内相对路径,前缀由编排层拼)。
*
* <p>逐项对齐 {@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<ToolParam> 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(",")