feat(通知公告): 发布表单接入模块页同款「通知对象」与「正文」控件
build / build (push) Successful in 3m28s

按用户要求,把 NoticeFront 里这两块 UI 原样搬到助手表单(不再自制近似控件):

- 正文 → wangEditor v5(@wangeditor/editor + @wangeditor/editor-for-vue)。
  提交给后端的是 **HTML 字符串**,与模块页 NoticeRequest.content 一致;
  插图上传走 /api/file/UploadImage、预览走 /api/file/Preview?id=(地址由后端下发,
  统一由编排层拼模块前缀)。异步 chunk 加载,避免把 800KB 编辑器塞进首屏。
- 通知对象 → 1:1 搬运模块页 components/UserCheckInput.vue + UserCheckModal.vue:
  radio「全部用户 / 指定用户」+ 只读回显 + +/清空 + 选人弹窗(左 zTree 组织树勾选级联 /
  右已选列表 / 全选 / 删除选中 / 按名字搜索回勾);v-model 值是字符串("all" 或 id 逗号串)。
- 全局依赖 jQuery + zTree 按模块页的方式由 index.html 全局加载(public/libs/),
  不用 npm 引入 —— 保证加载顺序(zTree 必须在 jQuery 之后)与样式/图标与模块页完全一致。
  已确认模块页的 lodash 实际未被使用,故不引入。

FormCard 新增两个 widget 分支,每一处都对齐模块页口径:

- richtext:初值取字符串、提交原 HTML,只读摘要用剥标签后的纯文本;
- userpicker:初值数组 join / 字符串原样(空则 allToken)、提交字符串;
  ★ 跳过下拉字典的「拍平 + 取值对齐」—— 那条路会把人员 id 串判成「认不出的值」清掉,
    等于把模型/用户已经选好的人悄悄抹掉;
- 两者都独占整行(自适应多列会把单选组挤到换行)。

验证:.scratch/verify-notice-publish-widgets.mjs 真机 38/38(含正文真实打字、
两个数据源页签、勾选回显、以及提交体 receiveList 必须是字符串 "101"、content 必须是 <p>…)。
npm run build 通过(RichTextEditor 单独成 chunk)。
This commit is contained in:
lijing
2026-10-14 15:19:08 +08:00
parent 3dc6cc561a
commit 6610623cf6
19 changed files with 1960 additions and 3 deletions
+241
View File
@@ -0,0 +1,241 @@
<template>
<!-- 通知对象选择:全部用户 / 指定用户(自业务模块 NoticeFront 的 components/UserCheckInput.vue 照搬) -->
<div class="check-user-wrap">
<div class="check-user-box">
<RadioGroup v-model="isCheckUserAll" class="radio" @on-change="userAllCheck">
<Radio :disabled="disabled || editDisabled" :label="0">{{ allText }}</Radio>
<Radio :disabled="disabled" :label="1">指定用户</Radio>
</RadioGroup>
<Input
type="text"
v-model="userNameStr"
readonly
:disabled="disabled || !isCheckUserAll"
placeholder="选择指定的用户"
class="input"
></Input>
<Button icon="md-add" :disabled="disabled || !isCheckUserAll" @click="checkUserShow"></Button>
<Button icon="md-trash" :disabled="disabled || !isCheckUserAll" @click="resetUser"></Button>
</div>
<UserCheckModal ref="userCheckModal" :sources="tabs" :userids="tempUserIds" @on-submit="submitUser" />
</div>
</template>
<script lang="ts">
/**
* 「通知对象」输入控件 —— 自业务模块 NoticeFront 的 `components/UserCheckInput.vue` **照搬**。
*
* <p>v-model 值是**字符串**:`"all"` 或用户 id 逗号串(与模块页 `form.rdoname` 一致);
* 切到「指定用户」会直接弹出选人弹窗(左组织树 / 右已选列表)。
* 提交时由后端按 `joinSeparator` 拼串,这里原样给出字符串即可。
*
* <p>与本体的**唯一区别是数据从哪儿来**:模块里是 alova 直连自己的 `/api/*`,
* 这里改为后端下发的 `sources[].call`(`FormField.pickerSources`),
* 由 `callModuleApi` 带**用户登录态**去取 —— 模块接口只认模块容器的 CAS 会话,服务端代调必 302。
* 取回的是**原始嵌套树**,不做拍平(拍平会丢层级,树就画不出来)。
*/
import UserCheckModal from "@/components/UserCheckModal.vue";
import { callModuleApi } from "@/utils/module-session";
import type { BrowserToolCall } from "@/types/agent";
/** 后端下发的数据源页签(每个 call 都已带模块前缀) */
interface PickerSource {
name: string;
call: BrowserToolCall;
}
/**
* 把模块返回的原始响应解成**组织树数组**。
*
* <p>与 `parseOptionItems` 分开写:那个是给下拉用的,会把树**拍平成叶子**
* (下拉里当然不该出现「年级」这一层)。选人弹窗要的恰恰是层级本身,
* 所以这里只做「剥掉 {code,msg,data} 外壳」,不动结构。
*/
function parseTree(body?: string): any[] {
if (!body) return [];
let payload: any;
try {
payload = JSON.parse(body);
} catch {
return [];
}
if (payload && typeof payload === "object" && !Array.isArray(payload) && "code" in payload) {
if (Number(payload.code) !== 0) return [];
payload = payload.data;
}
if (Array.isArray(payload)) return payload;
// 少数版本把数组包在 data.list / data.records 里,容错取第一个数组
if (payload && typeof payload === "object") {
const arr = Object.values(payload).find(v => Array.isArray(v));
if (arr) return arr as any[];
}
return [];
}
export default {
name: "UserCheckInput",
components: { UserCheckModal },
props: {
/** 当前值:`"all"` 或用户 id 逗号串 */
modelValue: {
type: String,
default: "all",
},
/** 选人弹窗的数据源页签(后端下发的 pickerSources,已带模块前缀) */
sources: {
type: Array as () => PickerSource[],
default: () => [],
},
/** 兜底数据源(后端只下发了 optionCall 时用;页签名固定「选择用户」) */
fallbackCall: {
type: Object as () => BrowserToolCall | undefined,
default: undefined,
},
/** 「全部」哨兵值(后端下发,默认 all) */
allToken: {
type: String,
default: "all",
},
/** 「全部」选项的显示名(后端下发,默认「全部用户」) */
allLabel: {
type: String,
default: "全部用户",
},
/** 编辑态:把「全部用户」显示成「现有用户」(模块页同款) */
isEdit: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:modelValue", "names"],
data() {
return {
isCheckUserAll: 0 as number,
tempUserIds: [] as any[],
userNameStr: "",
editDisabled: false,
};
},
computed: {
/** 弹窗的页签:{ name, loader }(loader 返回原始组织树) */
tabs(): { name: string; loader: () => Promise<any[]> }[] {
const srcs: PickerSource[] = this.sources || [];
if (srcs.length) {
return srcs.map(s => ({
name: s.name,
loader: async () => parseTree((await callModuleApi(s.call)).body),
}));
}
const fb = this.fallbackCall;
if (fb) {
return [{ name: "选择用户", loader: async () => parseTree((await callModuleApi(fb)).body) }];
}
return [];
},
allText(): string {
return this.isEdit ? "现有用户" : this.allLabel;
},
},
created() {
this.syncFromValue();
},
methods: {
/** 按当前 v-model 值同步界面(初值与外部改动共用一份口径) */
syncFromValue() {
const v = this.modelValue;
if (v === this.allToken || !v) {
this.isCheckUserAll = 0;
this.userNameStr = v === this.allToken ? this.allText : "";
} else {
this.isCheckUserAll = 1;
this.tempUserIds = parseIntList(v);
}
},
checkUserShow() {
(this.$refs.userCheckModal as any)?.show();
},
resetUser() {
// 清空指定用户 → 回到「全部用户」
this.tempUserIds = [];
this.userNameStr = "";
this.$emit("update:modelValue", this.allToken);
this.$emit("names", this.allText);
},
userAllCheck(type: number) {
const tempIds = parseIntList(this.modelValue);
if (type == 0) {
if (this.isEdit) {
this.tempUserIds = tempIds;
this.userNameStr = "现有用户";
this.$emit("update:modelValue", String(tempIds));
this.$emit("names", "现有用户");
} else {
this.tempUserIds = [];
this.userNameStr = this.allLabel;
this.$emit("update:modelValue", this.allToken);
this.$emit("names", this.allLabel);
}
} else {
if (this.isEdit) {
this.editDisabled = true;
}
this.tempUserIds = tempIds;
this.userNameStr = "";
this.checkUserShow();
}
},
submitUser(arr: any[]) {
// 确认用户
this.userNameStr = arr.map((x: any) => x.name).join("、");
const ids = arr.map((x: any) => x.id).join(",");
this.$emit("update:modelValue", ids || this.allToken);
this.$emit("names", this.userNameStr || this.allText);
if (!arr.length) {
// 一个人都没选 → 回到「全部用户」(模块页同款)
this.isCheckUserAll = 0;
this.userNameStr = this.allLabel;
this.$emit("update:modelValue", this.allToken);
this.$emit("names", this.allLabel);
}
},
},
watch: {
modelValue() {
this.syncFromValue();
},
},
};
/** `"101,102"` → `[101,102]`(非数字段丢掉,与模块页 `split().map(parseInt).filter` 同口径) */
function parseIntList(v: any): number[] {
return String(v || "")
.split(",")
.map(x => parseInt(x))
.filter(x => !isNaN(x));
}
</script>
<style scoped>
.check-user-wrap {
width: 100%;
}
.check-user-box {
display: flex;
align-items: center;
gap: 8px;
}
.check-user-box .radio {
width: 180px;
flex: none;
font-size: 12px;
vertical-align: middle;
line-height: 32px;
}
.check-user-box .input {
flex: 1;
}
</style>