前端已把 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:
@@ -692,15 +692,32 @@ public class AgentOrchestrator {
|
|||||||
* @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload}
|
* @param override {@link ToolParam#getUploadPathOverride()},为空时用模块默认的 {@code /api/base/FileUpload}
|
||||||
*/
|
*/
|
||||||
public static String resolveUploadPath(String module, String moduleBaseUrl, String override) {
|
public static String resolveUploadPath(String module, String moduleBaseUrl, String override) {
|
||||||
|
return resolveModulePath(module, moduleBaseUrl, override, "/api/base/FileUpload");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把「模块内相对路径」拼成「相对站点根」的路径({@link #resolveUploadPath} 的通用形态)。
|
||||||
|
*
|
||||||
|
* <p>附件上传、正文图片、选人弹窗的数据源都走这一条 —— 它们的共同点是
|
||||||
|
* 「地址只写模块内相对部分,前缀由编排层拼」。早先 {@code profession.publish} 把
|
||||||
|
* {@code /api/file/Upload} 当完整路径直接下发,线上打到站点根 ⇒ 404(老坑,别再来一次)。
|
||||||
|
*
|
||||||
|
* @param relFallback {@code rel} 为空时的默认相对路径
|
||||||
|
*/
|
||||||
|
public static String resolveModulePath(String module, String moduleBaseUrl, String rel, String relFallback) {
|
||||||
String prefix = pathPrefix(module, moduleBaseUrl);
|
String prefix = pathPrefix(module, moduleBaseUrl);
|
||||||
String rel = StringUtils.isNotBlank(override) ? override.trim() : "/api/base/FileUpload";
|
String path = StringUtils.isNotBlank(rel) ? rel.trim() : relFallback;
|
||||||
if (!rel.startsWith("/")) {
|
if (StringUtils.isBlank(path)) {
|
||||||
rel = "/" + rel;
|
return "";
|
||||||
}
|
}
|
||||||
if (rel.equals(prefix) || rel.startsWith(prefix + "/")) {
|
if (!path.startsWith("/")) {
|
||||||
return rel;
|
path = "/" + path;
|
||||||
}
|
}
|
||||||
return prefix + rel;
|
// 已经带了前缀的写法保持幂等(/notice/api/... 不会再被拼成 /notice/notice/api/...)
|
||||||
|
if (path.equals(prefix) || path.startsWith(prefix + "/")) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return prefix + path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 由工具定义生成补全表单 */
|
/** 由工具定义生成补全表单 */
|
||||||
@@ -740,6 +757,38 @@ public class AgentOrchestrator {
|
|||||||
f.setUploadPath(resolveUploadPath(def.getModule(),
|
f.setUploadPath(resolveUploadPath(def.getModule(),
|
||||||
settings.getModules().get(def.getModule()), p.getUploadPathOverride()));
|
settings.getModules().get(def.getModule()), p.getUploadPathOverride()));
|
||||||
}
|
}
|
||||||
|
// 富文本正文:正文里「插入图片」用的是**另一个**接口(UploadImage / Preview),
|
||||||
|
// 与附件的 FileUpload 不是一回事,所以单独下发两条(同样只写相对路径,前缀在这里拼)。
|
||||||
|
if ("richtext".equals(f.getWidget())) {
|
||||||
|
String baseUrl = settings.getModules().get(def.getModule());
|
||||||
|
f.setRichTextUploadPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextUploadOverride()));
|
||||||
|
f.setRichTextPreviewPath(resolveUploadPath(def.getModule(), baseUrl, p.getRichTextPreviewOverride()));
|
||||||
|
}
|
||||||
|
// 「通知对象」自绘控件(userpicker)的选人弹窗数据源:模块页有两个页签
|
||||||
|
// (基础平台组织树 / 自定义组),optionCall 只能描述一个地址,其余由这里补齐。
|
||||||
|
// 同样只写相对路径,前缀在这里拼(见 resolveModulePath)。
|
||||||
|
if ("userpicker".equals(f.getWidget()) && p.getPickerSources() != null) {
|
||||||
|
String baseUrl = settings.getModules().get(def.getModule());
|
||||||
|
List<FormField.PickerSource> srcs = new ArrayList<>();
|
||||||
|
for (String[] ps : p.getPickerSources()) {
|
||||||
|
if (ps == null || ps.length < 2 || StringUtils.isBlank(ps[1])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BrowserToolRequest.BrowserCall call = new BrowserToolRequest.BrowserCall();
|
||||||
|
call.setName("picker." + ps[0]);
|
||||||
|
call.setTitle("选择用户 · " + ps[0]);
|
||||||
|
call.setWrite(false);
|
||||||
|
call.setMethod("GET");
|
||||||
|
call.setPathAndQuery(resolveModulePath(def.getModule(), baseUrl, ps[1], null));
|
||||||
|
FormField.PickerSource src = new FormField.PickerSource();
|
||||||
|
src.setName(ps[0]);
|
||||||
|
src.setCall(call);
|
||||||
|
srcs.add(src);
|
||||||
|
}
|
||||||
|
if (!srcs.isEmpty()) {
|
||||||
|
f.setPickerSources(srcs);
|
||||||
|
}
|
||||||
|
}
|
||||||
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
// 字典类字段(如请假类型、班级、学生):优先交给前端用**用户自己的登录态**代取。
|
||||||
//
|
//
|
||||||
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
|
// ★ 为什么不在这里直接执行字典工具:业务模块的用户态接口只认浏览器 CAS 会话,
|
||||||
@@ -750,10 +799,14 @@ public class AgentOrchestrator {
|
|||||||
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
|
BrowserToolRequest.BrowserCall optionCall = buildOptionCall(p, caller);
|
||||||
if (optionCall != null) {
|
if (optionCall != null) {
|
||||||
f.setOptionCall(optionCall);
|
f.setOptionCall(optionCall);
|
||||||
// ★ 多值控件必须保留原样:multiselect(接收班级/宿舍)、scopes(公告推送范围)、
|
// ★ 多值/自绘控件必须保留原样:
|
||||||
// flow(公告审批流)都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力。
|
// multiselect(接收班级/宿舍)、scopes(公告推送范围)、flow(公告审批流)
|
||||||
|
// 都是「选项从字典代取 + 用户多选」,降级成单选下拉会直接丢掉多选能力;
|
||||||
|
// userpicker(通知公告的「通知对象」)是**自绘控件**(radio 全部/指定 + 选人弹窗),
|
||||||
|
// 它自己会用 optionCall 去取组织树 —— 降级成 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)) {
|
||||||
f.setWidget("select");
|
f.setWidget("select");
|
||||||
}
|
}
|
||||||
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
|
// 有内置兜底的(如请假类型 病假/事假)先摆上,前端取到学校实时字典后覆盖
|
||||||
@@ -764,7 +817,8 @@ public class AgentOrchestrator {
|
|||||||
if (!opts.isEmpty()) {
|
if (!opts.isEmpty()) {
|
||||||
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)) {
|
||||||
f.setWidget("select");
|
f.setWidget("select");
|
||||||
}
|
}
|
||||||
f.setOptionsFallback(false);
|
f.setOptionsFallback(false);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public class FormField {
|
|||||||
/** 中文标签,如「开始时间」 */
|
/** 中文标签,如「开始时间」 */
|
||||||
private String label;
|
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;
|
private String widget;
|
||||||
|
|
||||||
/** 数据类型:string / integer / number / boolean / array */
|
/** 数据类型:string / integer / number / boolean / array */
|
||||||
@@ -40,6 +40,37 @@ public class FormField {
|
|||||||
/** {@code widget=file} 时是否允许多个附件 */
|
/** {@code widget=file} 时是否允许多个附件 */
|
||||||
private boolean multiple;
|
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})与其显示名(如「全部宿舍」)。
|
* 「全部」哨兵值(如 {@code ALL})与其显示名(如「全部宿舍」)。
|
||||||
* <p>前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]};
|
* <p>前端把它作为多选的第一个选项;用户只勾它 → 提交 {@code ["ALL"]};
|
||||||
|
|||||||
@@ -129,6 +129,36 @@ public class ToolParam {
|
|||||||
*/
|
*/
|
||||||
private String uploadPathOverride;
|
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;
|
private boolean hidden;
|
||||||
|
|
||||||
@@ -476,6 +506,38 @@ public class ToolParam {
|
|||||||
return this;
|
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() {
|
public String labelOf() {
|
||||||
return StringUtils.isNotBlank(label) ? label : name;
|
return StringUtils.isNotBlank(label) ? label : name;
|
||||||
@@ -578,6 +640,9 @@ public class ToolParam {
|
|||||||
f.setSwitchOff(switchOff);
|
f.setSwitchOff(switchOff);
|
||||||
f.setRows(rows);
|
f.setRows(rows);
|
||||||
f.setMaxLength(maxLength);
|
f.setMaxLength(maxLength);
|
||||||
|
// 富文本正文的图片上传/预览地址(相对路径,前缀在编排层拼)
|
||||||
|
f.setRichTextUploadPath(richTextUploadOverride);
|
||||||
|
f.setRichTextPreviewPath(richTextPreviewOverride);
|
||||||
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
// 选项是内置兜底(不是学校实时字典)时如实告知,前端可提示用户
|
||||||
f.setOptionsFallback(isUsingFallbackOptions());
|
f.setOptionsFallback(isUsingFallbackOptions());
|
||||||
return f;
|
return f;
|
||||||
|
|||||||
@@ -33,6 +33,24 @@ public final class NoticeTools {
|
|||||||
* 这里只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveUploadPath 统一拼。 */
|
* 这里只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveUploadPath 统一拼。 */
|
||||||
private static final String UPLOAD_PATH = "/api/base/FileUpload";
|
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() {
|
private NoticeTools() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +117,17 @@ public final class NoticeTools {
|
|||||||
.label("要编辑的通知"),
|
.label("要编辑的通知"),
|
||||||
ToolParam.required("title", "string", "通知标题(50 字以内)")
|
ToolParam.required("title", "string", "通知标题(50 字以内)")
|
||||||
.label("通知标题"),
|
.label("通知标题"),
|
||||||
ToolParam.optional("content", "string", "通知正文(纯文本)")
|
ToolParam.optional("content", "string", "通知正文(富文本 HTML)")
|
||||||
.label("通知正文").as("textarea"),
|
.label("通知正文").as("richtext")
|
||||||
|
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH),
|
||||||
ToolParam.optional("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
|
ToolParam.optional("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
|
||||||
.label("来源部门")
|
.label("来源部门")
|
||||||
.optionsFrom("notify.deptDics").optionsKeys("k", "v"),
|
.optionsFrom("notify.deptDics").optionsKeys("k", "v"),
|
||||||
ToolParam.optional("receiveList", "array", "通知对象:留空或含全部 = 全部用户")
|
ToolParam.optional("receiveList", "array", "通知对象:留空或含全部 = 全部用户")
|
||||||
.label("通知对象")
|
.label("通知对象").as("userpicker")
|
||||||
.optionsFrom("notify.schoolAll").optionsKeys("id", "name")
|
.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", "全部用户"),
|
.join(",").allOption("all", "全部用户"),
|
||||||
ToolParam.optional("fileIds", "array", "附件文件 id 列表")
|
ToolParam.optional("fileIds", "array", "附件文件 id 列表")
|
||||||
.label("添加附件").as("file").multiple(true).join(",")
|
.label("添加附件").as("file").multiple(true).join(",")
|
||||||
@@ -168,15 +189,22 @@ public final class NoticeTools {
|
|||||||
List<ToolParam> ps = new ArrayList<>();
|
List<ToolParam> ps = new ArrayList<>();
|
||||||
ps.add(ToolParam.required("title", "string", "通知标题(50 字以内)")
|
ps.add(ToolParam.required("title", "string", "通知标题(50 字以内)")
|
||||||
.label("通知标题").eg("关于本周宿舍熄灯时间调整的通知"));
|
.label("通知标题").eg("关于本周宿舍熄灯时间调整的通知"));
|
||||||
ps.add(ToolParam.optional("content", "string", "通知正文(纯文本,对应模块页的富文本编辑器)")
|
ps.add(ToolParam.optional("content", "string", "通知正文(富文本 HTML,对应模块页的 wangEditor)")
|
||||||
.label("通知正文").as("textarea").eg("各位老师:经学校研究决定……"));
|
.label("通知正文").as("richtext")
|
||||||
|
.richText(RICHTEXT_UPLOAD_PATH, RICHTEXT_PREVIEW_PATH)
|
||||||
|
.eg("各位老师:经学校研究决定……"));
|
||||||
ps.add(ToolParam.required("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
|
ps.add(ToolParam.required("deptId", "string", "来源部门 id,来自 notify.deptDics 的 k 字段")
|
||||||
.label("来源部门")
|
.label("来源部门")
|
||||||
.optionsFrom("notify.deptDics").optionsKeys("k", "v"));
|
.optionsFrom("notify.deptDics").optionsKeys("k", "v"));
|
||||||
// 模块页的 UserCheckInput:v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string)
|
// 模块页的 UserCheckInput:radio「全部用户 / 指定用户」+ 选人弹窗(左组织树 / 右已选列表)。
|
||||||
ps.add(ToolParam.required("receiveList", "array", "通知对象:留空或含全部 = 全部用户;也可多选具体用户")
|
// v-model 值为 "all" 或用户 id 逗号串(NOTICE_REQ.receiveList 是 string),**取值契约与多选完全一致**
|
||||||
.label("通知对象")
|
// —— 换的只是控件外观。选项仍由 notify.schoolAll 代取(弹窗里的树直接用它取原始嵌套结构,
|
||||||
|
// 不走 parseOptionItems 的拍平,否则层级会丢)。
|
||||||
|
ps.add(ToolParam.required("receiveList", "array", "通知对象:\"all\" 或用户 id 逗号串")
|
||||||
|
.label("通知对象").as("userpicker")
|
||||||
.optionsFrom("notify.schoolAll").optionsKeys("id", "name")
|
.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", "全部用户"));
|
.join(",").allOption("all", "全部用户"));
|
||||||
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
|
ps.add(ToolParam.optional("fileIds", "array", "附件文件 id 列表(由表单的附件上传得到)")
|
||||||
.label("添加附件").as("file").multiple(true).join(",")
|
.label("添加附件").as("file").multiple(true).join(",")
|
||||||
|
|||||||
@@ -228,9 +228,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());
|
||||||
|
|
||||||
// 标题(单行)+ 正文(多行)
|
// 标题(单行)+ 正文(富文本,与模块页的 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("/notice/api/file/UploadImage", content.getRichTextUploadPath(),
|
||||||
|
"正文里插图片要打模块真实前缀 /notice(且不是附件的 FileUpload)");
|
||||||
|
assertEquals("/notice/api/file/Preview?id={id}", content.getRichTextPreviewPath(),
|
||||||
|
"图片预览地址模板同样要带前缀,{id} 留给前端替换");
|
||||||
|
|
||||||
// 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类)
|
// 来源部门:下拉 + 代取地址(带上模块前缀与固定字典种类)
|
||||||
FormField deptId = field(resp, "deptId");
|
FormField deptId = field(resp, "deptId");
|
||||||
@@ -241,11 +246,32 @@ class IntentDirectFormTest {
|
|||||||
assertEquals("k", deptId.getValueKey());
|
assertEquals("k", deptId.getValueKey());
|
||||||
assertEquals("v", deptId.getLabelKey());
|
assertEquals("v", deptId.getLabelKey());
|
||||||
|
|
||||||
// 通知对象:按用户多选(与校园公告的「推送范围」按班级完全不同)
|
// 通知对象:模块页同款自绘控件(radio 全部用户/指定用户 + 选人弹窗)。
|
||||||
|
// ★ 它也有 optionCall,必须确认**没有被降级成 select** ——
|
||||||
|
// AgentOrchestrator 对「带 optionCall 的字段」会把控件改写成 select(防止取回字典后还是输入框),
|
||||||
|
// userpicker 靠白名单豁免;漏了就变成普通下拉,正是这次要避免的。
|
||||||
FormField receiveList = field(resp, "receiveList");
|
FormField receiveList = field(resp, "receiveList");
|
||||||
assertEquals("multiselect", receiveList.getWidget());
|
assertEquals("userpicker", receiveList.getWidget(),
|
||||||
assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址");
|
"通知对象应是自绘的「全部用户 / 指定用户」+ 选人弹窗,不能被降级成 select");
|
||||||
|
assertNotNull(receiveList.getOptionCall(), "通知对象应带上组织树代取地址(弹窗里用它取原始树)");
|
||||||
assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery());
|
assertEquals("/notice/api/base/GetSchoolAll", receiveList.getOptionCall().getPathAndQuery());
|
||||||
|
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵值");
|
||||||
|
|
||||||
|
// ★ 选人弹窗有两个页签(模块页 UserCheckInput.vue 的 sources)——这里钉住**拼好前缀**的结果。
|
||||||
|
// 与附件上传同一个坑:只写相对路径、前缀由 resolveModulePath 拼,漏了就线上 404。
|
||||||
|
// 注意「自定义组」的路径里带 query,拼前缀时不能被吃掉。
|
||||||
|
assertNotNull(receiveList.getPickerSources(), "选人弹窗的数据源页签要下发到前端");
|
||||||
|
assertEquals(2, receiveList.getPickerSources().size());
|
||||||
|
FormField.PickerSource tab0 = receiveList.getPickerSources().get(0);
|
||||||
|
assertEquals("基础平台", tab0.getName());
|
||||||
|
assertNotNull(tab0.getCall());
|
||||||
|
assertEquals("GET", tab0.getCall().getMethod(), "取组织树是只读 GET");
|
||||||
|
assertEquals("/notice/api/base/GetSchoolAll", tab0.getCall().getPathAndQuery(),
|
||||||
|
"代取地址必须带模块前缀 /notice");
|
||||||
|
FormField.PickerSource tab1 = receiveList.getPickerSources().get(1);
|
||||||
|
assertEquals("自定义组", tab1.getName());
|
||||||
|
assertEquals("/notice/api/contacts/GetGroupTree?type=1", tab1.getCall().getPathAndQuery(),
|
||||||
|
"自定义组树同样要带前缀,且 type=1 要保留");
|
||||||
|
|
||||||
// 校园公告独有的字段绝不能串到通知公告的表单上
|
// 校园公告独有的字段绝不能串到通知公告的表单上
|
||||||
for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) {
|
for (String foreign : new String[]{"pushScopes", "publishType", "receiptFlag", "topmost", "type"}) {
|
||||||
|
|||||||
@@ -539,12 +539,22 @@ class ModuleFormParityTest {
|
|||||||
// 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue)
|
// 收件人是「用户」:v-model 为 "all" 或用户 id 逗号串(components/UserCheckInput.vue)
|
||||||
ToolParam receive = param(def, "receiveList");
|
ToolParam receive = param(def, "receiveList");
|
||||||
assertNotNull(receive, "通知公告按用户下发,必须有 receiveList");
|
assertNotNull(receive, "通知公告按用户下发,必须有 receiveList");
|
||||||
// ★ 用 resolveWidget() 而不是 getWidget():receiveList 没有显式 .as("multiselect"),
|
// ★ 2026-10-14:控件换成模块页同款的自绘组件(radio 全部/指定 + 选人弹窗),
|
||||||
// 它是「type=array + optionsFrom 字典」自然推断出的多选(见 ToolParam#resolveWidget)。
|
// 故这里是**显式** .as("userpicker"),不再靠 type/optionsFrom 推断。
|
||||||
assertEquals("multiselect", receive.resolveWidget());
|
assertEquals("userpicker", receive.resolveWidget(),
|
||||||
|
"通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗)");
|
||||||
assertEquals(",", receive.getJoinSeparator());
|
assertEquals(",", receive.getJoinSeparator());
|
||||||
assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all");
|
assertEquals("all", receive.getAllToken(), "勾「全部用户」提交的就是 all");
|
||||||
assertEquals("notify.schoolAll", receive.getOptionsFrom());
|
assertEquals("notify.schoolAll", receive.getOptionsFrom(),
|
||||||
|
"弹窗里的组织树仍由 notify.schoolAll 代取(前端取**原始嵌套树**,不拍平)");
|
||||||
|
|
||||||
|
// 正文换成富文本(模块页 NewNotice.vue 用 wangEditor v5),并带上「插入图片」的两个地址
|
||||||
|
ToolParam content = param(def, "content");
|
||||||
|
assertEquals("richtext", content.resolveWidget(), "正文用富文本编辑器");
|
||||||
|
assertEquals("/api/file/UploadImage", content.getRichTextUploadOverride(),
|
||||||
|
"图片上传是独立接口(不是附件的 FileUpload);只写相对路径,前缀由编排层拼");
|
||||||
|
assertEquals("/api/file/Preview?id={id}", content.getRichTextPreviewOverride(),
|
||||||
|
"正文内图片必须用 Preview(inline),Down 是 attachment 内嵌显示不出来");
|
||||||
|
|
||||||
// 校园公告那一套字段一个都不该出现在这里
|
// 校园公告那一套字段一个都不该出现在这里
|
||||||
assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段");
|
assertNull(param(def, "pushScopes"), "pushScopes(推送范围)是校园公告的字段");
|
||||||
@@ -620,6 +630,19 @@ class ModuleFormParityTest {
|
|||||||
assertEquals("101", com.alibaba.fastjson.JSON
|
assertEquals("101", com.alibaba.fastjson.JSON
|
||||||
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
|
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
|
||||||
"哨兵与具体项互斥:同时选中时具体项优先");
|
"哨兵与具体项互斥:同时选中时具体项优先");
|
||||||
|
|
||||||
|
/* ★ 2026-10-14:前端换成 UserCheckInput 后,表单里存的就是**字符串**("all" / "101,102"),
|
||||||
|
* 不再是数组。这条短路极易出错:normalizeJsonArgs 的第二个循环会把「array 型参数却是标量」
|
||||||
|
* 包成 ["101,102"](整串塞进一个元素),而模块的 NoticeRequest.receiveList 是 String —— 判非法。
|
||||||
|
* 之所以现在没事,是因为第一个循环按 joinSeparator 拼过之后就 continue 了。
|
||||||
|
* 把这个「字符串原样透传」的口径钉住,防止谁把那个 continue 删掉。 */
|
||||||
|
args.put("receiveList", "101,102");
|
||||||
|
assertEquals("101,102", com.alibaba.fastjson.JSON
|
||||||
|
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"),
|
||||||
|
"userpicker 直接给逗号串时不能再被包成数组");
|
||||||
|
args.put("receiveList", "all");
|
||||||
|
assertEquals("all", com.alibaba.fastjson.JSON
|
||||||
|
.parseObject(executor.prepareBrowserCall(def, args).getBody()).getString("receiveList"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 辅助 ==================== */
|
/* ==================== 辅助 ==================== */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import org.springframework.web.client.RestTemplate;
|
|||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
@@ -131,13 +132,28 @@ class NotifyIntentFormTest {
|
|||||||
void notifyPublishCarriesCommaJoinedReceiveListWithAllSentinel() {
|
void notifyPublishCarriesCommaJoinedReceiveListWithAllSentinel() {
|
||||||
ToolDef publish = registry.get("notify.publish");
|
ToolDef publish = registry.get("notify.publish");
|
||||||
ToolParam receiveList = param(publish, "receiveList");
|
ToolParam receiveList = param(publish, "receiveList");
|
||||||
assertEquals("multiselect", receiveList.resolveWidget(),
|
assertEquals("userpicker", receiveList.resolveWidget(),
|
||||||
"通知对象按用户多选(校园公告那边是按班级的 scopes 控件)");
|
"通知对象用模块页同款 UserCheckInput(radio 全部用户/指定用户 + 选人弹窗),"
|
||||||
|
+ "不再是通用多选下拉;取值契约不变");
|
||||||
assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串");
|
assertEquals(",", receiveList.getJoinSeparator(), "wire 上是逗号串");
|
||||||
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵是 all");
|
assertEquals("all", receiveList.getAllToken(), "「全部用户」哨兵是 all");
|
||||||
assertEquals("notify.schoolAll", receiveList.getOptionsFrom());
|
assertEquals("notify.schoolAll", receiveList.getOptionsFrom());
|
||||||
assertEquals("id", receiveList.getOptionValueKey());
|
assertEquals("id", receiveList.getOptionValueKey());
|
||||||
assertEquals("name", receiveList.getOptionLabelKey());
|
assertEquals("name", receiveList.getOptionLabelKey());
|
||||||
|
|
||||||
|
// ★ 选人弹窗的**两个页签**(照搬 UserCheckInput.vue 的 sources):
|
||||||
|
// optionCall 只能描述一个地址,所以「自定义组」由 pickerSource 补。
|
||||||
|
// 两者都只写**模块内相对路径**,前缀由 AgentOrchestrator.resolveModulePath 拼 ——
|
||||||
|
// 这里钉住这一点:漏拼前缀就是线上 404(附件上传已经因此出过两次事)。
|
||||||
|
List<String[]> sources = receiveList.getPickerSources();
|
||||||
|
assertNotNull(sources, "通知对象的选人弹窗要有数据源页签");
|
||||||
|
assertEquals(2, sources.size(), "模块页有两个页签:基础平台 / 自定义组");
|
||||||
|
assertEquals("基础平台", sources.get(0)[0]);
|
||||||
|
assertEquals("/api/base/GetSchoolAll", sources.get(0)[1],
|
||||||
|
"只写模块内相对路径,不带 /notice 前缀");
|
||||||
|
assertEquals("自定义组", sources.get(1)[0]);
|
||||||
|
assertEquals("/api/contacts/GetGroupTree?type=1", sources.get(1)[1],
|
||||||
|
"自定义组树带 type=1(与 settingApi.getGroupTree 一致)");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 下拉选项的代取地址(必须带模块前缀 /notice) ==================== */
|
/* ==================== 下拉选项的代取地址(必须带模块前缀 /notice) ==================== */
|
||||||
|
|||||||
Reference in New Issue
Block a user