`@wangeditor/*` 是重量级依赖(编辑器 + 工具栏 + 样式)。整个助手只有「通知公告」
+ * 的发布表单用得上,静态引入等于让所有用户都背上这份体积,所以拆包按需加载。
+ */
+const RichTextEditor = defineAsyncComponent(() => import("@/components/RichTextEditor.vue"));
+
const props = defineProps<{
form: FormRequest;
submitted?: boolean;
@@ -410,6 +419,28 @@ function approverNames(f: FormField, ids: any[]): string {
return ids.map(id => opts.find(o => String(o.value) === String(id))?.label || String(id)).join("、");
}
+/* ==================== 通知对象(widget=userpicker,搬运自通知公告模块页) ==================== */
+/**
+ * 「通知对象」的显示文本(按字段名)。
+ *
+ * 为什么要在外面存一份:控件内部只持有 id 串(`"101,102"`),提交后只读摘要里
+ * 直接摆一串 id 是没法看的。控件在选中/清空时会 `@names` 回传它自己的可读文本
+ * (如「张三、李四」),这里接住用于摘要。
+ */
+const pickerText = reactive>({});
+
+/** 选人弹窗的页签(后端下发;没有时退回 optionCall 的单页签) */
+function pickerSourcesOf(f: FormField) {
+ return f.pickerSources && f.pickerSources.length ? f.pickerSources : [];
+}
+
+/**
+ * 本表单是否是「编辑」场景 —— 由是否有 `sourceId`(要改的那条记录 id)判断。
+ * 编辑态下「全部用户」要显示成「现有用户」(模块页 `:isEdit` 同款口径):
+ * 定向发送过的通知不能再改成全校广播。
+ */
+const isEditForm = computed(() => (props.form?.fields || []).some(f => f.name === "sourceId"));
+
/* ==================== 模块页同款呈现:自动选中 / 单选项隐藏 / 快捷时段 / 逐天时段 ==================== */
/** widget=switch 的勾选态(真正提交的是 switchOn / switchOff 映射后的值) */
@@ -598,7 +629,7 @@ function widgetOf(f: FormField) {
* 附件与审批流更是必须整行。时间类字段保持并排(「开始 ~ 结束」与模块页一致)。
*/
function isWide(f: FormField) {
- if (["flow", "radio", "switch", "textarea", "file"].includes(f.widget)) return true;
+ if (["flow", "radio", "switch", "textarea", "file", "richtext", "userpicker"].includes(f.widget)) return true;
// 「连续天数」是「连续请假」按天展开的从属字段,跟着独占一行
return (props.form?.fields || []).some(x => x.dailyExpand?.count === f.name);
}
@@ -677,6 +708,11 @@ async function reloadOptions(f: FormField) {
function syncOptions() {
for (const f of props.form?.fields || []) {
if (!f.optionCall) continue;
+ // ★ userpicker(通知对象)跳过:它的 optionCall 是给**选人弹窗**用的原始组织树,
+ // 弹窗自己取、自己用嵌套结构。这里再按「下拉字典」取一遍会把树拍平成叶子,
+ // 而且 alignToOptions 会把「具体人员 id 串」判成「认不出的值」直接清掉 ——
+ // 那等于把模型已经识别出来的人悄悄抹掉。
+ if (f.widget === "userpicker") continue;
const sig = depSignature(f);
if (depSig[f.name] === sig) continue;
const hadSig = depSig[f.name] !== undefined;
@@ -718,6 +754,7 @@ function initValues() {
Object.keys(switchOn).forEach(k => delete switchOn[k]);
Object.keys(dailyRows).forEach(k => delete dailyRows[k]);
Object.keys(hiddenSingle).forEach(k => delete hiddenSingle[k]);
+ Object.keys(pickerText).forEach(k => delete pickerText[k]);
// 栏目动态字段:换表单就整体丢弃(取值、区划树缓存都不跨表单复用)
extraFields.value = [];
extraState.value = "idle";
@@ -748,6 +785,20 @@ function initValues() {
values[f.name] = alignToOptions(f, arr.length ? arr : f.allToken ? [f.allToken] : []);
} else if (f.widget === "file") {
values[f.name] = Array.isArray(seed) ? [...seed] : [];
+ } else if (f.widget === "userpicker") {
+ // 通知对象:v-model 是**字符串**(`"all"` 或用户 id 逗号串),不是数组 ——
+ // 虽然声明成 array(wire 上要逗号串),但模块页 form.rdoname 初值就是 "all"。
+ // 模型已经点出具体人时可能是数组,join 成模块要的形态。
+ const token = f.allToken || "all";
+ const raw = Array.isArray(seed) ? seed.join(",") : seed === undefined || seed === null ? "" : String(seed);
+ values[f.name] = raw || token;
+ pickerText[f.name] =
+ raw && raw !== token
+ ? `已选 ${raw.split(",").filter(x => String(x).trim() !== "").length} 人`
+ : f.allTokenLabel || "全部用户";
+ } else if (f.widget === "richtext") {
+ // 正文:HTML 字符串(与模块页 NoticeRequest.content 一致)
+ values[f.name] = seed === undefined || seed === null ? "" : String(seed);
} else if (f.widget === "select" || f.widget === "number" || f.widget === "radio") {
// ★ 对齐选项取值:模型按 schema 给的是数字(integer)或名称(string),
// 这里统一成「选项本身的值」,否则预填了也选不上,用户以为要再选一次
@@ -876,6 +927,22 @@ function labelOfName(name: string): string {
return f?.label || NAME_LABEL[name] || name;
}
+/** 富文本 HTML → 摘要里可读的纯文本(只用于展示,提交的仍是原 HTML) */
+function plainTextOfHtml(html: string): string {
+ const text = String(html || "")
+ .replace(/
/gi, " ")
+ .replace(/<\/(p|div|li|h[1-6])>/gi, " ")
+ .replace(/<[^>]*>/g, "")
+ .replace(/ /gi, " ")
+ .replace(/</gi, "<")
+ .replace(/>/gi, ">")
+ .replace(/&/gi, "&")
+ .replace(/\s+/g, " ")
+ .trim();
+ if (!text) return "(空)";
+ return text.length > 60 ? `${text.slice(0, 60)}…` : text;
+}
+
/**
* 动态下拉的状态提示:等待上级字段 / 正在读取 / 取不到(可手填编号)。
* 有了它,用户不会对着一个空下拉框猜「为什么点不开」。
@@ -959,6 +1026,15 @@ function onSubmit() {
});
}
}
+ // 「通知对象」:值是**字符串**(`"all"` 或 id 逗号串),不能走下面的数组/空值分支 ——
+ // 空值在这里补回「全部」(模块页 form.rdoname 从不留空)。
+ if (f.widget === "userpicker") {
+ const token = f.allToken || "all";
+ const s = values[f.name] === undefined || values[f.name] === null ? "" : String(values[f.name]).trim();
+ args[f.name] = s || token;
+ rows.push({ label: f.label, text: pickerText[f.name] || s || f.allTokenLabel || "全部用户" });
+ continue;
+ }
const v = values[f.name];
// 数组类字段即使是空数组也要提交(模块页就是恒带 fileIds:"" / attachments:[]),
// 否则「没传」与「传空」在 wire 上不一致,后端拿到的可能是 null 而不是空集合。
@@ -989,6 +1065,13 @@ function onSubmit() {
rows.push({ label: f.label, text: objs.map(o => o.fileName).join("、") || `${objs.length} 个附件` });
continue;
}
+ // 富文本正文:提交的是**完整 HTML**(与模块页 NoticeRequest.content 一致);
+ // 但摘要里不能摆原始标签,剥成纯文本给人看。
+ if (f.widget === "richtext") {
+ args[f.name] = String(v);
+ rows.push({ label: f.label, text: plainTextOfHtml(String(v)) });
+ continue;
+ }
const normalized = normalize(f, v);
args[f.name] = normalized;
const opt = fieldOptions(f).find(o => String(o.value) === String(normalized));
@@ -1161,6 +1244,32 @@ function reset() {
{{ f.placeholder || "可选,支持图片 / 文档 / 压缩包" }}
+
+
+ (pickerText[f.name] = t)"
+ />
+
+
+
+
+
+
+
选择审批人后走审批流程(不勾选 = 直接发布)
@@ -1469,6 +1578,16 @@ function reset() {
width: 100%;
}
+/* 通知对象(搬自模块页):radio + 只读回显 + +/清空 一行排开 */
+.field__picker {
+ width: 100%;
+}
+
+/* 富文本正文:编辑器自己带边框,这里只管占满整行 */
+.field__richtext {
+ width: 100%;
+}
+
.field__label {
display: block;
margin-bottom: 6px;
diff --git a/src/components/RichTextEditor.vue b/src/components/RichTextEditor.vue
new file mode 100644
index 0000000..7cfab85
--- /dev/null
+++ b/src/components/RichTextEditor.vue
@@ -0,0 +1,193 @@
+
+
+
+
+
+ emit('update:modelValue', html)"
+ />
+
+
+
+
diff --git a/src/components/UserCheckInput.vue b/src/components/UserCheckInput.vue
new file mode 100644
index 0000000..d3b7080
--- /dev/null
+++ b/src/components/UserCheckInput.vue
@@ -0,0 +1,241 @@
+
+
+
+
+
+ {{ allText }}
+ 指定用户
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/UserCheckModal.vue b/src/components/UserCheckModal.vue
new file mode 100644
index 0000000..12456e7
--- /dev/null
+++ b/src/components/UserCheckModal.vue
@@ -0,0 +1,437 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
全选
+
{{ tempUserList.length }}
+
删除选中
+
+
+ -
+ {{ item.name }}
+ {{ item.group }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/types/agent.ts b/src/types/agent.ts
index 2acef21..ebb1c2f 100644
--- a/src/types/agent.ts
+++ b/src/types/agent.ts
@@ -52,7 +52,18 @@ export interface FormField {
/** 勾选框(对齐模块页的 Checkbox,如「连续请假」) */
| "switch"
/** 级联选择(栏目动态字段的 province_city:省市区,值存编码数组) */
- | "cascader";
+ | "cascader"
+ /**
+ * 富文本正文(wangEditor,对齐通知公告模块页的编辑器)。
+ * 提交给后端的是 **HTML 字符串**,与模块页 NoticeRequest.content 一致。
+ */
+ | "richtext"
+ /**
+ * 「通知对象」自绘控件(模块页 components/UserCheckInput.vue 的同款):
+ * radio「全部用户 / 指定用户」+ 只读回显 + +/清空 + 选人弹窗(左组织树右已选列表)。
+ * v-model 值是 **字符串**:`"all"` 或用户 id 逗号串。
+ */
+ | "userpicker";
type?: string;
required: boolean;
/** 字段说明(含义与格式) */
@@ -71,6 +82,21 @@ export interface FormField {
uploadPath?: string;
/** widget=file:是否允许多个附件 */
multiple?: boolean;
+ /**
+ * widget=richtext:正文里「插入图片」的上传地址(已带模块前缀的绝对相对路径)。
+ * 与 uploadPath 不是同一个接口(正文图片走 /api/file/UploadImage)。
+ */
+ richTextUploadPath?: string;
+ /** widget=richtext:正文内图片的预览地址模板(已带模块前缀,含 {id} 占位) */
+ richTextPreviewPath?: string;
+ /**
+ * widget=userpicker:选人弹窗的**数据源页签**(后端下发,每个 call 都已带模块前缀)。
+ *
+ * 模块页的弹窗有两个页签(「基础平台」组织树 / 「自定义组」);
+ * 未下发时前端退回 `optionCall`(单页签)。取回的是**原始嵌套树**,不做拍平 ——
+ * 拍平会丢掉层级,树就画不出来。
+ */
+ pickerSources?: { name: string; call: BrowserToolCall }[];
/** 「全部」哨兵值(如 ALL):作为多选的第一项,可选中代表「全部班级 / 全部宿舍」 */
allToken?: string;
/** 「全部」选项的显示名 */