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 去掉视频是本模块的既有选择;全屏在对话面板里会把整个助手界面顶飞。
+ * 去掉视频是各模块的既有选择;全屏在对话面板里会把整个助手界面顶飞。
+ *
+ * ★ 后端没下发上传地址时(如**专业发展**:它的模块页编辑器压根没配 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 @@
+
+
+ 与 `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`(单页签)。取回的是**原始嵌套树**,不做拍平 ——
- * 拍平会丢掉层级,树就画不出来。
+ * 语义按控件而定:
+ * 为什么与 `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 [];
+}
+ *
*/
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)共用的响应解析。
+ *
+ *