彻底对齐各模块「新建 / 发布」页里的两类控件(此前只有通知公告搬到位):
正文 → 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:
@@ -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 前缀)由
|
||||||
|
|||||||
Reference in New Issue
Block a user