diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index 5381f94..f675e62 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -17,13 +17,26 @@ import { reactive, computed, ref, watch, defineAsyncComponent } from "vue"; import { Message } from "view-ui-plus"; import { callModuleApi, parseOptionItems, isLoginRedirect, warmModuleSession } from "@/utils/module-session"; import UserCheckInput from "@/components/UserCheckInput.vue"; +import ScopePickerInput from "@/components/ScopePickerInput.vue"; +import RoomPickerInput from "@/components/RoomPickerInput.vue"; import type { FormField, FormRequest } from "@/types/agent"; +/** + * 是不是「自绘选择控件」——它们的选项来自弹窗里的树/表(pickerSources), + * **不是**下拉字典。凡是「取选项 / 拍平 / 降级成下拉」的地方都要绕开它们。 + * + *

userpicker 通知对象 · scopepicker 接收班级 · roompicker 接收宿舍。 + */ +function isSelfDrawnPicker(f: FormField): boolean { + return f.widget === "userpicker" || f.widget === "scopepicker" || f.widget === "roompicker"; +} + /** * 富文本正文编辑器(wangEditor)——**异步加载**。 * - *

`@wangeditor/*` 是重量级依赖(编辑器 + 工具栏 + 样式)。整个助手只有「通知公告」 - * 的发布表单用得上,静态引入等于让所有用户都背上这份体积,所以拆包按需加载。 + *

`@wangeditor/*` 是重量级依赖(编辑器 + 工具栏 + 样式)。整个助手只有几个 + * 「发布」表单(通知公告 / 校园公告 / 宿舍通知 / 家校活动 / 作业 / 专业发展)用得上, + * 静态引入等于让所有用户都背上这份体积,所以拆包按需加载。 */ const RichTextEditor = defineAsyncComponent(() => import("@/components/RichTextEditor.vue")); @@ -419,17 +432,18 @@ 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,搬运自通知公告模块页) ==================== */ +/* ==================== 自绘选择控件(userpicker / scopepicker / roompicker) ==================== */ /** - * 「通知对象」的显示文本(按字段名)。 + * 自绘控件显示文本(按字段名)。 * - *

为什么要在外面存一份:控件内部只持有 id 串(`"101,102"`),提交后只读摘要里 - * 直接摆一串 id 是没法看的。控件在选中/清空时会 `@names` 回传它自己的可读文本 - * (如「张三、李四」),这里接住用于摘要。 + *

为什么要在外面存一份:控件内部只持有 id 串 / id 串(`"101,102"`),提交后只读摘要里 + * 直接摆一串 id 是没法看的。各控件在选中/清空时会 `@names` 回传它自己的可读文本 + * (如「张三、李四」/「二年级(1)班」/「3 间宿舍 · 12 名住宿生|1 号楼(101、102)」), + * 这里接住用于摘要。 */ const pickerText = reactive>({}); -/** 选人弹窗的页签(后端下发;没有时退回 optionCall 的单页签) */ +/** 自绘控件的数据源(后端下发;userpicker 没有时退回 optionCall 的单页签) */ function pickerSourcesOf(f: FormField) { return f.pickerSources && f.pickerSources.length ? f.pickerSources : []; } @@ -629,7 +643,9 @@ function widgetOf(f: FormField) { * 附件与审批流更是必须整行。时间类字段保持并排(「开始 ~ 结束」与模块页一致)。 */ function isWide(f: FormField) { - if (["flow", "radio", "switch", "textarea", "file", "richtext", "userpicker"].includes(f.widget)) return true; + if (["flow", "radio", "switch", "textarea", "file", "richtext", "userpicker", "scopepicker", "roompicker"].includes(f.widget)) { + return true; + } // 「连续天数」是「连续请假」按天展开的从属字段,跟着独占一行 return (props.form?.fields || []).some(x => x.dailyExpand?.count === f.name); } @@ -708,11 +724,12 @@ 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; + // ★ 自绘选择控件(userpicker / scopepicker / roompicker)全部跳过: + // 它们的 optionCall 只是「选项来自哪个字典工具」的说明,真正的数据由弹窗按 + // pickerSources 自己取(原始嵌套结构)。这里再按「下拉字典」取一遍会把树拍平成叶子, + // 而且 alignToOptions 会把「具体人员 id 串 / scope 对象数组 / 房间 id 串」 + // 判成「认不出的值」直接清掉 —— 那等于把模型/用户已经选好的东西悄悄抹掉。 + if (isSelfDrawnPicker(f)) continue; const sig = depSignature(f); if (depSig[f.name] === sig) continue; const hadSig = depSig[f.name] !== undefined; @@ -796,6 +813,21 @@ function initValues() { raw && raw !== token ? `已选 ${raw.split(",").filter(x => String(x).trim() !== "").length} 人` : f.allTokenLabel || "全部用户"; + } else if (f.widget === "scopepicker") { + // 接收班级:v-model 是 **scope 对象数组**([{scopeType,scopeId,scopeName}])。 + // 没有初值时给空数组即可 —— 控件 created() 会按模块页口径回落「全部班级」并回传。 + values[f.name] = Array.isArray(seed) ? [...seed] : seed ? [seed] : []; + pickerText[f.name] = f.allTokenLabel || "全部班级"; + } else if (f.widget === "roompicker") { + // 接收宿舍:v-model 是**字符串**(房间 id 逗号串 / 全选哨兵),不是数组 —— + // 与模块页 receiveList("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); @@ -1026,13 +1058,27 @@ function onSubmit() { }); } } - // 「通知对象」:值是**字符串**(`"all"` 或 id 逗号串),不能走下面的数组/空值分支 —— - // 空值在这里补回「全部」(模块页 form.rdoname 从不留空)。 - if (f.widget === "userpicker") { - const token = f.allToken || "all"; + // 「通知对象」/「接收宿舍」:值是**字符串**(`"all"` / 房间 id 逗号串 / 全选哨兵), + // 不能走下面的数组/空值分支 —— 空值在这里补回「全部」 + // (模块页 form.rdoname 从不留空;宿舍的 receiveList 口径本身就是「留空或含 ALL = 全部」)。 + if (f.widget === "userpicker" || f.widget === "roompicker") { + const isRoom = f.widget === "roompicker"; + const token = f.allToken || (isRoom ? "ALL" : "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 || "全部用户" }); + rows.push({ + label: f.label, + text: pickerText[f.name] || s || f.allTokenLabel || (isRoom ? "全部宿舍" : "全部用户"), + }); + continue; + } + // 「接收班级」(校园公告 pushScopes):控件直接回传 scope 对象数组, + // 这里**原样提交**(不再是「按选项 id 反查名称」——那是旧的多选下拉口径)。 + if (f.widget === "scopepicker") { + const arr = Array.isArray(values[f.name]) ? values[f.name] : []; + const payload = arr.length ? arr : [{ scopeType: 1, scopeId: 0, scopeName: f.allTokenLabel || "全部班级" }]; + args[f.name] = payload; + rows.push({ label: f.label, text: pickerText[f.name] || payload.map(p => p.scopeName).join("、") }); continue; } const v = values[f.name]; @@ -1259,6 +1305,32 @@ function reset() { /> + +

+ +
+ + +
+ +
+
上传同样带**用户自己的登录态**:模块接口只认模块容器的 CAS 会话,服务端代调必 302。 * 遇 {@code code=302} 先预热模块会话再重试一次(与附件上传、下拉代取同一条链路)。 */ -import { ref, shallowRef, onBeforeUnmount, watch } from "vue"; +import { computed, shallowRef, onBeforeUnmount, watch } from "vue"; import { Editor, Toolbar } from "@wangeditor/editor-for-vue"; import "@wangeditor/editor/dist/css/style.css"; import { Message } from "view-ui-plus"; @@ -49,9 +49,17 @@ const editorRef = shallowRef(null); /** * 工具栏与模块页一致:去掉「上传视频」和「全屏」。 - *

去掉视频是本模块的既有选择;全屏在对话面板里会把整个助手界面顶飞。 + *

去掉视频是各模块的既有选择;全屏在对话面板里会把整个助手界面顶飞。 + * + *

★ 后端没下发上传地址时(如**专业发展**:它的模块页编辑器压根没配 uploadImage、 + * file 域也没有 UploadImage 接口),把「图片」整组去掉 —— 留一个点了必失败的按钮 + * 比没有按钮更糟。有地址时保持与模块页完全一致。 */ -const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] }; +const toolbarConfig = computed(() => { + const excludeKeys = ["group-video", "fullScreen"]; + if (!props.uploadPath) excludeKeys.push("group-image"); + return { excludeKeys }; +}); /** 图片预览地址(把后端给的模板里的 `{id}` 换掉) */ function previewUrl(id: any): string { diff --git a/src/components/RoomPickerInput.vue b/src/components/RoomPickerInput.vue new file mode 100644 index 0000000..b16d303 --- /dev/null +++ b/src/components/RoomPickerInput.vue @@ -0,0 +1,430 @@ + + + + + diff --git a/src/components/ScopePickerInput.vue b/src/components/ScopePickerInput.vue new file mode 100644 index 0000000..a4a2ba2 --- /dev/null +++ b/src/components/ScopePickerInput.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/src/components/UserCheckInput.vue b/src/components/UserCheckInput.vue index d3b7080..72b81de 100644 --- a/src/components/UserCheckInput.vue +++ b/src/components/UserCheckInput.vue @@ -36,6 +36,7 @@ */ import UserCheckModal from "@/components/UserCheckModal.vue"; import { callModuleApi } from "@/utils/module-session"; +import { parseTree } from "@/utils/picker-tree"; import type { BrowserToolCall } from "@/types/agent"; /** 后端下发的数据源页签(每个 call 都已带模块前缀) */ @@ -44,34 +45,6 @@ interface PickerSource { call: BrowserToolCall; } -/** - * 把模块返回的原始响应解成**组织树数组**。 - * - *

与 `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 }, diff --git a/src/types/agent.ts b/src/types/agent.ts index ebb1c2f..f89997e 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -63,7 +63,19 @@ export interface FormField { * radio「全部用户 / 指定用户」+ 只读回显 + +/清空 + 选人弹窗(左组织树右已选列表)。 * v-model 值是 **字符串**:`"all"` 或用户 id 逗号串。 */ - | "userpicker"; + | "userpicker" + /** + * 「接收班级」自绘控件(校园公告模块页 components/ClassScopeInput.vue 的同款): + * radio「全部班级 / 指定班级」+ 只读回显 + +/清空 + 弹窗班级树(复用选人弹窗)。 + * v-model 是 **scope 对象数组**:`[{scopeType,scopeId,scopeName}]`。 + */ + | "scopepicker" + /** + * 「接收宿舍」自绘控件(宿舍通知模块页那块「选择接收宿舍」弹窗的同款): + * 左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数。 + * v-model 值是 **字符串**:房间 id 逗号串,或全选哨兵(默认 `ALL`)。 + */ + | "roompicker"; type?: string; required: boolean; /** 字段说明(含义与格式) */ @@ -90,11 +102,17 @@ export interface FormField { /** widget=richtext:正文内图片的预览地址模板(已带模块前缀,含 {id} 占位) */ richTextPreviewPath?: string; /** - * widget=userpicker:选人弹窗的**数据源页签**(后端下发,每个 call 都已带模块前缀)。 + * 自绘选择控件(userpicker / scopepicker / roompicker)的数据源(后端下发, + * 每个 call 都已带模块前缀)。取回的是**原始嵌套树/列表**,不做拍平 —— 拍平会丢掉层级。 * - *

模块页的弹窗有两个页签(「基础平台」组织树 / 「自定义组」); - * 未下发时前端退回 `optionCall`(单页签)。取回的是**原始嵌套树**,不做拍平 —— - * 拍平会丢掉层级,树就画不出来。 + *

语义按控件而定: + *

    + *
  • {@code userpicker}:**多个页签**(「基础平台」组织树 / 「自定义组」); + * 未下发时前端退回 `optionCall`(单页签)。
  • + *
  • {@code scopepicker}:单页签,班级树(`getStuTree?toUser=false`)。
  • + *
  • {@code roompicker}:**主从两张表**,`[0]` 楼栋列表、`[1]` 房间列表 + * (前端切楼栋时自行追加 `?buildingId=`)。
  • + *
*/ pickerSources?: { name: string; call: BrowserToolCall }[]; /** 「全部」哨兵值(如 ALL):作为多选的第一项,可选中代表「全部班级 / 全部宿舍」 */ diff --git a/src/utils/picker-tree.ts b/src/utils/picker-tree.ts new file mode 100644 index 0000000..563d6b8 --- /dev/null +++ b/src/utils/picker-tree.ts @@ -0,0 +1,27 @@ +/** + * 自绘选择控件(userpicker / scopepicker / roompicker)共用的响应解析。 + * + *

为什么与 `parseOptionItems` 分开写:那个是给**下拉**用的,会把树**拍平成叶子** + * (下拉里当然不该出现「年级」这一层)。自绘控件的弹窗要的恰恰是层级/原始结构本身, + * 所以这里只做「剥掉 {code,msg,data} 外壳」,不动内部结构。 + */ +export function parseTree(body?: string | null): 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 []; +}