feat(表单): 正文接入模块页同款富文本,公告/宿舍的接收对象接入自绘控件
build / build (push) Successful in 2m43s

承接上一提交(通知公告的两个控件),把其余模块里同类控件一次搬到位。

新增组件(都照搬对应模块页的实现,只把「数据从哪来」换成后端下发的 pickerSources)
  · ScopePickerInput.vue ← 校园公告 components/ClassScopeInput.vue
    (全部班级 / 指定班级 + 只读回显 + 弹窗班级树;v-model 是 scope 对象数组)
  · RoomPickerInput.vue ← 宿舍通知 views/dorm/NewNotice.vue 的「选择接收宿舍」弹窗
    (左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数;v-model 是字符串)
  · utils/picker-tree.ts:只剥 {code,msg,data} 外壳、**不拍平**
    (弹窗要的是原始嵌套树;拍平是下拉字典的口径)

改造
  · RichTextEditor:后端没下发正文图片上传地址时,把工具栏的「图片」整组摘掉
    —— 专业发展的编辑器就是这样(该模块没有 UploadImage)
  · UserCheckInput:抽出公共 parseTree,三个自绘控件共用一份外壳剥离口径
  · FormCard:接入 scopepicker / roompicker(初值、序列化、isWide 独占整行)
  · types/agent.ts:widget 联合类型补 scopepicker / roompicker
This commit is contained in:
lijing
2026-10-14 16:56:50 +08:00
parent 6610623cf6
commit b0f31608d4
7 changed files with 954 additions and 55 deletions
+91 -19
View File
@@ -17,13 +17,26 @@ import { reactive, computed, ref, watch, defineAsyncComponent } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { callModuleApi, parseOptionItems, isLoginRedirect, warmModuleSession } from "@/utils/module-session"; import { callModuleApi, parseOptionItems, isLoginRedirect, warmModuleSession } from "@/utils/module-session";
import UserCheckInput from "@/components/UserCheckInput.vue"; 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"; import type { FormField, FormRequest } from "@/types/agent";
/**
* 是不是「自绘选择控件」——它们的选项来自弹窗里的树/表(pickerSources),
* **不是**下拉字典。凡是「取选项 / 拍平 / 降级成下拉」的地方都要绕开它们。
*
* <p>userpicker 通知对象 · scopepicker 接收班级 · roompicker 接收宿舍。
*/
function isSelfDrawnPicker(f: FormField): boolean {
return f.widget === "userpicker" || f.widget === "scopepicker" || f.widget === "roompicker";
}
/** /**
* 富文本正文编辑器(wangEditor)——**异步加载**。 * 富文本正文编辑器(wangEditor)——**异步加载**。
* *
* <p>`@wangeditor/*` 是重量级依赖(编辑器 + 工具栏 + 样式)。整个助手只有「通知公告」 * <p>`@wangeditor/*` 是重量级依赖(编辑器 + 工具栏 + 样式)。整个助手只有几个
* 的发布表单用得上,静态引入等于让所有用户都背上这份体积,所以拆包按需加载。 * 「发布」表单(通知公告 / 校园公告 / 宿舍通知 / 家校活动 / 作业 / 专业发展)用得上,
* 静态引入等于让所有用户都背上这份体积,所以拆包按需加载。
*/ */
const RichTextEditor = defineAsyncComponent(() => import("@/components/RichTextEditor.vue")); 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("、"); return ids.map(id => opts.find(o => String(o.value) === String(id))?.label || String(id)).join("、");
} }
/* ==================== 通知对象(widget=userpicker,搬运自通知公告模块页) ==================== */ /* ==================== 自绘选择控件(userpicker / scopepicker / roompicker) ==================== */
/** /**
* 「通知对象」的显示文本(按字段名)。 * 自绘控件显示文本(按字段名)。
* *
* <p>为什么要在外面存一份:控件内部只持有 id 串(`"101,102"`),提交后只读摘要里 * <p>为什么要在外面存一份:控件内部只持有 id 串 / id 串(`"101,102"`),提交后只读摘要里
* 直接摆一串 id 是没法看的。控件在选中/清空时会 `@names` 回传它自己的可读文本 * 直接摆一串 id 是没法看的。各控件在选中/清空时会 `@names` 回传它自己的可读文本
* (如「张三、李四」),这里接住用于摘要。 * (如「张三、李四」/「二年级(1)班」/「3 间宿舍 · 12 名住宿生|1 号楼(101、102)」),
* 这里接住用于摘要。
*/ */
const pickerText = reactive<Record<string, string>>({}); const pickerText = reactive<Record<string, string>>({});
/** 选人弹窗的页签(后端下发;没有时退回 optionCall 的单页签) */ /** 自绘控件的数据源(后端下发;userpicker 没有时退回 optionCall 的单页签) */
function pickerSourcesOf(f: FormField) { function pickerSourcesOf(f: FormField) {
return f.pickerSources && f.pickerSources.length ? f.pickerSources : []; return f.pickerSources && f.pickerSources.length ? f.pickerSources : [];
} }
@@ -629,7 +643,9 @@ function widgetOf(f: FormField) {
* 附件与审批流更是必须整行。时间类字段保持并排(「开始 ~ 结束」与模块页一致)。 * 附件与审批流更是必须整行。时间类字段保持并排(「开始 ~ 结束」与模块页一致)。
*/ */
function isWide(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); return (props.form?.fields || []).some(x => x.dailyExpand?.count === f.name);
} }
@@ -708,11 +724,12 @@ async function reloadOptions(f: FormField) {
function syncOptions() { function syncOptions() {
for (const f of props.form?.fields || []) { for (const f of props.form?.fields || []) {
if (!f.optionCall) continue; if (!f.optionCall) continue;
// ★ userpicker(通知对象)跳过:它的 optionCall 是给**选人弹窗**用的原始组织树, // ★ 自绘选择控件(userpicker / scopepicker / roompicker)全部跳过:
// 弹窗自己取、自己用嵌套结构。这里再按「下拉字典」取一遍会把树拍平成叶子, // 它们的 optionCall 只是「选项来自哪个字典工具」的说明,真正的数据由弹窗按
// 而且 alignToOptions 会把「具体人员 id 串」判成「认不出的值」直接清掉 —— // pickerSources 自己取(原始嵌套结构)。这里再按「下拉字典」取一遍会把树拍平成叶子,
// 那等于把模型已经识别出来的人悄悄抹掉。 // 而且 alignToOptions 会把「具体人员 id 串 / scope 对象数组 / 房间 id 串」
if (f.widget === "userpicker") continue; // 判成「认不出的值」直接清掉 —— 那等于把模型/用户已经选好的东西悄悄抹掉。
if (isSelfDrawnPicker(f)) continue;
const sig = depSignature(f); const sig = depSignature(f);
if (depSig[f.name] === sig) continue; if (depSig[f.name] === sig) continue;
const hadSig = depSig[f.name] !== undefined; const hadSig = depSig[f.name] !== undefined;
@@ -796,6 +813,21 @@ function initValues() {
raw && raw !== token raw && raw !== token
? `已选 ${raw.split(",").filter(x => String(x).trim() !== "").length} 人` ? `已选 ${raw.split(",").filter(x => String(x).trim() !== "").length} 人`
: f.allTokenLabel || "全部用户"; : 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") { } else if (f.widget === "richtext") {
// 正文:HTML 字符串(与模块页 NoticeRequest.content 一致) // 正文:HTML 字符串(与模块页 NoticeRequest.content 一致)
values[f.name] = seed === undefined || seed === null ? "" : String(seed); values[f.name] = seed === undefined || seed === null ? "" : String(seed);
@@ -1026,13 +1058,27 @@ function onSubmit() {
}); });
} }
} }
// 「通知对象」:值是**字符串**(`"all"` 或 id 逗号串),不能走下面的数组/空值分支 —— // 「通知对象」/「接收宿舍」:值是**字符串**(`"all"` / 房间 id 逗号串 / 全选哨兵),
// 空值在这里补回「全部」(模块页 form.rdoname 从不留空)。 // 不能走下面的数组/空值分支 —— 空值在这里补回「全部」
if (f.widget === "userpicker") { // (模块页 form.rdoname 从不留空;宿舍的 receiveList 口径本身就是「留空或含 ALL = 全部」)。
const token = f.allToken || "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(); const s = values[f.name] === undefined || values[f.name] === null ? "" : String(values[f.name]).trim();
args[f.name] = s || token; 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; continue;
} }
const v = values[f.name]; const v = values[f.name];
@@ -1259,6 +1305,32 @@ function reset() {
/> />
</div> </div>
<!-- 接收班级:搬自校园公告模块页的 ClassScopeInput(radio 全部/指定班级 +
弹窗班级树,复用同一个选人弹窗)。提交的是 [{scopeType,scopeId,scopeName}]。 -->
<div v-else-if="widgetOf(f) === 'scopepicker'" class="field__picker">
<ScopePickerInput
v-model="values[f.name]"
:sources="pickerSourcesOf(f)"
:fallback-call="f.optionCall"
:all-label="f.allTokenLabel || '全部班级'"
:disabled="disabled"
@names="(t: string) => (pickerText[f.name] = t)"
/>
</div>
<!-- 接收宿舍:搬自宿舍通知模块页那块「选择接收宿舍」弹窗
(左楼栋 / 右按楼层分组的房间勾选 / 底部间数与住宿生人数)。 -->
<div v-else-if="widgetOf(f) === 'roompicker'" class="field__picker">
<RoomPickerInput
v-model="values[f.name]"
:sources="pickerSourcesOf(f)"
:all-token="f.allToken || 'ALL'"
:all-label="f.allTokenLabel || '全部宿舍'"
:disabled="disabled"
@names="(t: string) => (pickerText[f.name] = t)"
/>
</div>
<!-- 富文本正文:搬自通知公告模块页的 wangEditor(提交的是 HTML 字符串) --> <!-- 富文本正文:搬自通知公告模块页的 wangEditor(提交的是 HTML 字符串) -->
<div v-else-if="widgetOf(f) === 'richtext'" class="field__richtext"> <div v-else-if="widgetOf(f) === 'richtext'" class="field__richtext">
<RichTextEditor <RichTextEditor
+11 -3
View File
@@ -23,7 +23,7 @@
* <p>上传同样带**用户自己的登录态**:模块接口只认模块容器的 CAS 会话,服务端代调必 302。 * <p>上传同样带**用户自己的登录态**:模块接口只认模块容器的 CAS 会话,服务端代调必 302。
* 遇 {@code code=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 { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css"; import "@wangeditor/editor/dist/css/style.css";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
@@ -49,9 +49,17 @@ const editorRef = shallowRef<any>(null);
/** /**
* 工具栏与模块页一致:去掉「上传视频」和「全屏」。 * 工具栏与模块页一致:去掉「上传视频」和「全屏」。
* <p>去掉视频是本模块的既有选择;全屏在对话面板里会把整个助手界面顶飞。 * <p>去掉视频是各模块的既有选择;全屏在对话面板里会把整个助手界面顶飞。
*
* <p>★ 后端没下发上传地址时(如**专业发展**:它的模块页编辑器压根没配 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}` 换掉) */ /** 图片预览地址(把后端给的模板里的 `{id}` 换掉) */
function previewUrl(id: any): string { function previewUrl(id: any): string {
+430
View File
@@ -0,0 +1,430 @@
<template>
<!-- 接收宿舍:搬自业务模块 DormNotice 的 views/dorm/NewNotice.vue「选择接收宿舍」弹窗
(左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数) -->
<div class="room-picker-input">
<div class="cls-row">
<Button icon="md-home" :disabled="disabled" @click="openPicker">选择宿舍</Button>
<Checkbox :model-value="allDorms" :disabled="disabled" @on-change="toggleAllDorms">全部宿舍</Checkbox>
<a v-if="!allDorms && pickedIds.length && !disabled" class="clear-link" @click="clearRooms">清空</a>
</div>
<div class="hint" :class="{ warn: !allDorms && !pickedIds.length }">{{ hintText }}</div>
<Modal v-model="modalOpen" title="选择接收宿舍" width="880px" :mask-closable="false">
<div class="room-picker">
<div class="bld-list">
<div
v-for="b in buildings"
:key="b.buildingId"
:class="['bld-item', currentBuildingId === b.buildingId ? 'active' : '']"
@click="selectBuilding(b)"
>
<div class="bld-name">{{ b.buildingName }}</div>
<div class="bld-meta">{{ b.roomCount }} 间 · {{ b.usedBeds }}/{{ b.bedCount }} 人</div>
</div>
<div v-if="!buildings.length" class="empty">
{{ (sources || []).length ? "暂无楼栋" : "宿舍数据源没取到,请稍后重试(或直接去系统里操作)" }}
</div>
</div>
<div v-if="curBuilding" class="room-area">
<div class="room-bar">
<span>{{ curBuilding.buildingName }}</span>
<span class="supervisor">宿管:{{ curBuilding.supervisorName }}({{ curBuilding.supervisorPhone }})</span>
<a class="fr" @click="toggleAllCurBuilding()">{{ allCurBuildingChecked() ? "取消本楼全选" : "全选本楼" }}</a>
</div>
<div v-for="g in floorGroups" :key="g.floor" class="floor-box">
<div class="floor-h">
<Checkbox
:model-value="floorAllChecked(g.rooms)"
:indeterminate="floorIndeterminate(g.rooms)"
@on-change="(v: boolean) => toggleRooms(g.rooms, v)"
>
{{ g.floor }} 层
</Checkbox>
<span class="floor-meta">{{ g.rooms.length }} 间 · {{ g.rooms.reduce((s: number, r: any) => s + r.usedBeds, 0) }} 人</span>
</div>
<div class="room-grid">
<Checkbox
v-for="r in g.rooms"
:key="r.roomId"
:model-value="isPicked(r.roomId)"
@on-change="(v: boolean) => toggleRoom(r, v)"
>
{{ r.roomNo }}({{ r.usedBeds }}/{{ r.bedCount }})
</Checkbox>
</div>
</div>
<div v-if="loading" class="empty">加载中…</div>
</div>
<div v-else class="room-area"><div class="empty">请先选择楼栋</div></div>
</div>
<div class="picked-bar">
已选:{{ allDorms ? "全部宿舍" : `${pickedIds.length} 间 · ${pickedBeds} 名住宿生` }}
</div>
<template #footer>
<Button @click="clearRooms">清空选择</Button>
<Button @click="modalOpen = false">取消</Button>
<Button type="primary" @click="confirmRooms">确定</Button>
</template>
</Modal>
</div>
</template>
<script lang="ts">
/**
* 「接收宿舍」输入控件 —— 自业务模块 DormNotice 的
* `views/dorm/NewNotice.vue` 里那块「选择接收宿舍」弹窗**照搬**。
*
* <p>v-model 是**字符串**:房间 id 逗号串(如 `"901,902"`),或全选哨兵(默认 `ALL`)——
* 与模块页 `receiveList: allDorms ? "ALL" : form.receiveList.join(",")` 完全一致。
*
* <p>与本体的**唯一区别是数据从哪儿来**:模块里是 alova 直连自己的
* `${DORM}/BuildingList` 与 `${DORM}/RoomList`,这里改为后端下发的 `sources[0] / sources[1]`
* (`FormField.pickerSources`,按顺序=楼栋、房间),由 `callModuleApi` 带**用户自己的登录态**取。
*
* <p>★ 两个数据源不是「页签」而是「主从」:房间列表在切楼栋时追加 `?buildingId=`;
* 若地址里本来就带 query,会追加成 `&buildingId=`。
*/
import { callModuleApi } from "@/utils/module-session";
import { parseTree } from "@/utils/picker-tree";
import type { BrowserToolCall } from "@/types/agent";
/** 后端下发的数据源(每个 call 都已带模块前缀) */
interface PickerSource {
name: string;
call: BrowserToolCall;
}
export default {
name: "RoomPickerInput",
props: {
/** 房间 id 逗号串,或全选哨兵 */
modelValue: { type: String, default: "" },
/** 后端下发的数据源:[0] 楼栋列表,[1] 房间列表 */
sources: { type: Array as () => PickerSource[], default: () => [] },
/** 全选哨兵(后端下发,默认 ALL) */
allToken: { type: String, default: "ALL" },
/** 全选显示名(后端下发,默认「全部宿舍」) */
allLabel: { type: String, default: "全部宿舍" },
disabled: { type: Boolean, default: false },
},
emits: ["update:modelValue", "names"],
data() {
return {
modalOpen: false,
allDorms: false,
pickedIds: [] as string[],
/** id → 房间对象(用于汇总文案;由楼栋/房间列表懒补全) */
pickedRooms: {} as Record<string, any>,
buildings: [] as any[],
rooms: [] as any[],
currentBuildingId: 0,
loading: false,
/** 自身 emit 引发的 modelValue 变化不回灌(防环) */
suppressWatch: false,
};
},
computed: {
buildingCall(): BrowserToolCall | undefined {
return (this.sources || [])[0]?.call;
},
roomCall(): BrowserToolCall | undefined {
return (this.sources || [])[1]?.call;
},
/** 当前楼栋(模板里不做复杂内联判断) */
curBuilding(): any {
return this.buildings.find(b => b.buildingId === this.currentBuildingId) || null;
},
/** 房间按楼层分组(与模块页 floorGroups 同口径) */
floorGroups(): { floor: number; rooms: any[] }[] {
const map = new Map<number, any[]>();
this.rooms.forEach(r => {
const list = map.get(r.floor) || [];
list.push(r);
map.set(r.floor, list);
});
return Array.from(map.entries())
.sort((a, b) => a[0] - b[0])
.map(([floor, rooms]) => ({ floor, rooms: rooms.sort((a, b) => String(a.roomNo).localeCompare(String(b.roomNo))) }));
},
/** 已选住宿生人数(信息未补全时按已知部分求和) */
pickedBeds(): number {
return this.pickedIds.reduce((s, id) => s + (this.pickedRooms[id]?.usedBeds || 0), 0);
},
/** 汇总文案(与模块页 pickedSummary 同口径) */
summaryText(): string {
if (this.allDorms) return "全部宿舍(所有楼栋房间)";
if (!this.pickedIds.length) return "";
const byBld = new Map<string, string[]>();
for (const id of this.pickedIds) {
const r = this.pickedRooms[id];
if (!r) continue;
const key = r.buildingName || "未分区";
if (!byBld.has(key)) byBld.set(key, []);
byBld.get(key)!.push(r.roomNo || String(id));
}
const groups = [...byBld.entries()].map(([b, rooms]) => `${b}(${rooms.join("、")})`);
const shown = groups.slice(0, 6).join("、");
const beds = this.pickedBeds ? ` · ${this.pickedBeds} 名住宿生` : "";
return `${this.pickedIds.length} 间宿舍${beds}${groups.length > 6 ? "…" : ""}${shown ? "|" + shown : ""}`;
},
hintText(): string {
if (this.summaryText) return this.summaryText;
return "发布前必须选择接收宿舍:宿舍通知按「房间」下发,应收人数取房间实际入住床位数";
},
},
created() {
this.syncFromValue();
},
methods: {
/** 按当前 v-model 值同步界面(初值与外部改动共用一份口径) */
syncFromValue() {
const v = String(this.modelValue ?? "").trim();
if (!v || v === this.allToken) {
// 留空按「全部」处理:后端 receiveList 的口径就是「留空或含 ALL = 全部宿舍」
this.allDorms = true;
this.pickedIds = [];
this.pickedRooms = {};
} else {
this.allDorms = false;
this.pickedIds = v.split(",").map(x => x.trim()).filter(Boolean);
this.fillRoomInfo();
}
this.$emit("names", this.allDorms ? this.allLabel : this.summaryText || "(未选择)");
},
/** 把 id 串补成房间对象(模块页回显时也是不带 buildingId 取全部房间来补) */
async fillRoomInfo() {
const missing = this.pickedIds.filter(id => !this.pickedRooms[id]);
if (!missing.length || !this.roomCall) return;
try {
const all = parseTree((await callModuleApi(this.roomCall)).body);
all.filter((r: any) => this.pickedIds.includes(String(r.roomId))).forEach((r: any) => {
this.pickedRooms[String(r.roomId)] = r;
});
} catch {
/* 忽略:房间信息缺失只影响汇总文案 */
}
},
emitValue() {
this.suppressWatch = true;
this.$emit("update:modelValue", this.allDorms ? this.allToken : this.pickedIds.join(","));
this.$emit("names", this.allDorms ? this.allLabel : this.summaryText || "(未选择)");
this.$nextTick(() => (this.suppressWatch = false));
},
async openPicker() {
if (this.disabled) return;
this.modalOpen = true;
if (!this.buildings.length) {
await this.loadBuildings();
}
if (!this.currentBuildingId && this.buildings.length) {
this.currentBuildingId = this.buildings[0].buildingId;
}
await this.loadRooms();
},
async loadBuildings() {
if (!this.buildingCall) return;
try {
this.buildings = parseTree((await callModuleApi(this.buildingCall)).body);
} catch (e: any) {
this.buildings = [];
console.warn("[roompicker] 楼栋加载失败", e);
}
},
async loadRooms() {
const bid = this.currentBuildingId;
if (!bid || !this.roomCall) {
this.rooms = [];
return;
}
this.loading = true;
try {
const call = { ...this.roomCall, pathAndQuery: appendQuery(this.roomCall.pathAndQuery, "buildingId", bid) };
this.rooms = parseTree((await callModuleApi(call)).body);
// 顺手把已选房间的信息补全
this.rooms.filter(r => this.pickedIds.includes(String(r.roomId))).forEach(r => {
this.pickedRooms[String(r.roomId)] = r;
});
} catch (e: any) {
this.rooms = [];
console.warn("[roompicker] 房间加载失败", e);
} finally {
this.loading = false;
}
},
selectBuilding(b: any) {
if (this.currentBuildingId === b.buildingId) return;
this.currentBuildingId = b.buildingId;
this.loadRooms();
},
isPicked(roomId: number) {
return this.pickedIds.includes(String(roomId));
},
toggleRoom(room: any, checked: boolean) {
this.allDorms = false;
const key = String(room.roomId);
if (checked) {
if (!this.pickedIds.includes(key)) this.pickedIds.push(key);
this.pickedRooms[key] = room;
} else {
this.pickedIds = this.pickedIds.filter(x => x !== key);
delete this.pickedRooms[key];
}
this.emitValue();
},
/** 整层/整栋勾选 */
toggleRooms(rooms: any[], checked: boolean) {
rooms.forEach(r => {
const key = String(r.roomId);
if (checked && !this.pickedIds.includes(key)) this.pickedIds.push(key);
if (!checked) this.pickedIds = this.pickedIds.filter(x => x !== key);
if (checked) this.pickedRooms[key] = r;
else delete this.pickedRooms[key];
});
this.allDorms = false;
this.emitValue();
},
floorAllChecked(rooms: any[]) {
return rooms.length > 0 && rooms.every(r => this.isPicked(r.roomId));
},
floorIndeterminate(rooms: any[]) {
const n = rooms.filter(r => this.isPicked(r.roomId)).length;
return n > 0 && n < rooms.length;
},
allCurBuildingChecked() {
return this.rooms.length > 0 && this.rooms.every(r => this.isPicked(r.roomId));
},
toggleAllCurBuilding() {
this.toggleRooms(this.rooms, !this.allCurBuildingChecked());
},
confirmRooms() {
this.modalOpen = false;
this.emitValue();
},
clearRooms() {
this.pickedIds = [];
this.pickedRooms = {};
this.allDorms = false;
this.emitValue();
},
toggleAllDorms(v: boolean) {
if (v) {
this.pickedIds = [];
this.pickedRooms = {};
}
this.allDorms = v;
this.emitValue();
},
},
watch: {
modelValue() {
if (!this.suppressWatch) this.syncFromValue();
},
},
};
/** 往地址里追加 query(已有 `?` 就用 `&`) */
function appendQuery(pathAndQuery: string, key: string, value: any): string {
const url = String(pathAndQuery || "");
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}${key}=${encodeURIComponent(String(value))}`;
}
</script>
<style scoped>
.room-picker-input {
width: 100%;
}
.cls-row {
display: flex;
align-items: center;
gap: 10px;
}
.clear-link {
font-size: 12px;
}
.hint {
margin-top: 6px;
font-size: 12px;
color: #808695;
line-height: 1.5;
}
.hint.warn {
color: #ed4014;
}
.room-picker {
display: flex;
gap: 12px;
min-height: 380px;
}
.bld-list {
width: 200px;
flex: none;
border-right: 1px solid #e8e8e8;
overflow-y: auto;
max-height: 420px;
}
.bld-item {
padding: 8px 10px;
border-radius: 4px;
cursor: pointer;
}
.bld-item.active {
background: #f0faff;
color: #2d8cf0;
}
.bld-name {
font-size: 13px;
}
.bld-meta {
font-size: 12px;
color: #808695;
}
.room-area {
flex: 1;
min-width: 0;
}
.room-bar {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
margin-bottom: 8px;
}
.room-bar .supervisor {
color: #808695;
font-size: 12px;
}
.room-bar .fr {
margin-left: auto;
}
.floor-box {
margin-bottom: 10px;
}
.floor-h {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.floor-meta {
font-size: 12px;
color: #808695;
}
.room-grid {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
}
.empty {
color: #808695;
font-size: 13px;
padding: 12px 0;
}
.picked-bar {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #e8e8e8;
font-size: 13px;
color: #515a6e;
}
</style>
+371
View File
@@ -0,0 +1,371 @@
<template>
<!-- 接收班级:全部班级 / 指定班级(自业务模块 CampusAnnouncement 的 ClassScopeInput.vue 照搬,
树选弹窗复用同一个 UserCheckModal) -->
<div class="class-scope-wrap">
<div class="check-user-box">
<RadioGroup v-model="mode" class="radio" @on-change="onScopeChange">
<Radio :disabled="disabled" :label="0">{{ allLabel }}</Radio>
<Radio :disabled="disabled" :label="1">指定班级</Radio>
</RadioGroup>
<Input
type="text"
v-model="classStr"
readonly
:disabled="disabled || mode === 0"
:placeholder="placeholder"
class="input"
></Input>
<Button icon="md-add" :disabled="disabled || mode === 0" @click="openPicker"></Button>
<Button icon="md-trash" :disabled="disabled || mode === 0" @click="reset"></Button>
</div>
<UserCheckModal ref="picker" :sources="tabs" :userids="classIds" @on-submit="submit" />
</div>
</template>
<script lang="ts">
/**
* 「接收班级」输入控件 —— 自业务模块 CampusAnnouncement 的 `components/ClassScopeInput.vue` **照搬**。
*
* <p>v-model = `PushScopeItem[]`:
* <ul>
* <li>全部班级 → `[{scopeType:1, scopeId:0, scopeName:"全部班级"}]`(后端 SCHOOL 忽略 scopeId 走全校学生);</li>
* <li>指定班级 → `[{scopeType:4, scopeId:classId, scopeName:className}…]`(弹窗提交)。</li>
* </ul>
* 编辑回显时 type 2/3(旧面板选的学部/年级)会在树中展开为下属班级,无法映射的(type 5/6 等)
* 原样保留随表单提交,防丢范围。
*
* <p>与本体的**唯一区别是数据从哪儿来**:模块里是 alova 直连 `${BASE}/getStuTree?toUser=false`,
* 这里改为后端下发的 `sources[].call`(`FormField.pickerSources`),由 `callModuleApi` 带
* **用户自己的登录态**去取(模块接口只认模块容器的 CAS 会话,服务端代调必 302)。
*
* <p>树仍是「学校→学段→年级→班级」的原始嵌套(`toUser=false`,不含学生叶子),
* 这里自底向上归成「年级→班级」两层喂给 zTree —— 后端没有 GetClassTree,别想着换地址。
*/
import UserCheckModal from "@/components/UserCheckModal.vue";
import { callModuleApi } from "@/utils/module-session";
import { parseTree } from "@/utils/picker-tree";
import type { BrowserToolCall } from "@/types/agent";
/** scope 项(与校园公告模块页 PushScopeItem 同形) */
interface ScopeItem {
scopeType: number;
scopeId: number;
scopeName: string;
}
/** 后端下发的数据源(每个 call 都已带模块前缀) */
interface PickerSource {
name: string;
call: BrowserToolCall;
}
/** 全部班级范围项(后端 scopeType=1 不解析 scopeId) */
const ALL_SCOPE: ScopeItem = { scopeType: 1, scopeId: 0, scopeName: "全部班级" };
export default {
name: "ScopePickerInput",
components: { UserCheckModal },
props: {
modelValue: { type: Array as () => ScopeItem[], default: () => [] },
/** 弹窗数据源(后端下发的 pickerSources,已带模块前缀) */
sources: { type: Array as () => PickerSource[], default: () => [] },
/** 兜底数据源(后端只下发了 optionCall 时用;校园公告的 notice.stuTree 本来就是同一个地址) */
fallbackCall: { type: Object as () => BrowserToolCall | undefined, default: undefined },
/** 「全部」选项的显示名(后端下发,默认「全部班级」) */
allLabel: { type: String, default: "全部班级" },
disabled: { type: Boolean, default: false },
},
emits: ["update:modelValue", "names"],
data() {
return {
/** 0=全部班级 1=指定班级 */
mode: 0 as number,
/** 指定班级已选班级(scopeType=4) */
classItems: [] as ScopeItem[],
/** 无法映射到班级的回显项(type 5/6 或树中已不存在的 2/3):原样保留提交 */
extras: [] as ScopeItem[],
classStr: "",
/** 自身 emit 引发的 modelValue 变化不回灌(防环) */
suppressWatch: false,
/** getStuTree 原始树(供名称解析与 2/3 展开) */
rawTree: [] as any[],
gradeTree: [] as any[],
treeLoading: false,
treeFailed: false,
/** 树未就绪时的回显缓冲(对齐模块页 pendingScopes 口径) */
pendingScopes: null as ScopeItem[] | null,
};
},
computed: {
/** 弹窗的页签:{ name, loader }(loader 返回给 zTree 的树) */
tabs(): { name: string; loader: () => Promise<any[]> }[] {
const srcs: PickerSource[] = this.sources || [];
if (srcs.length) {
return srcs.map((s, i) => ({
name: s.name,
/**
* ★ 第一个页签必须复用 {@link loadTree} —— 它返回的是 **「年级→班级」两层树**
* (`toGradeTree` 的结果)。模块页 ClassScopeInput 的 sources 就是
* `() => this.loadTree()`,喂给弹窗的正是这棵归好的树。
*
* <p>踩过的坑:这里若直接把 `getStuTree` 的**原始多层树**丢给弹窗
* (学校→学部→年级→班级),zTree 只展开第 0 层(`formatTree` 里
* `open = level == 0`),于是界面上只看到「小学部 / 初中部 / 一年级…」,
* 班级叶子全被折叠 —— 与模块页长得不一样,等于控件没搬到位。
*/
loader: i === 0 ? () => this.loadTree() : async () => parseTree((await callModuleApi(s.call)).body),
}));
}
const fb = this.fallbackCall;
if (fb) {
return [{ name: "班级", loader: () => this.loadTree() }];
}
return [];
},
/** 实际使用的第一个数据源(pickerSources 优先,退回 optionCall) */
primaryCall(): BrowserToolCall | undefined {
return (this.sources || [])[0]?.call || this.fallbackCall;
},
placeholder(): string {
return "选择班级";
},
classIds(): number[] {
return this.classItems.map(x => x.scopeId);
},
/** id → 组节点(学部/年级展开用) */
nodeById(): Map<number, any> {
const map = new Map<number, any>();
const walk = (ns?: any[] | null) => {
(ns || []).forEach(n => {
map.set(Number(n.id), n);
walk(n.children);
});
};
walk(this.rawTree);
return map;
},
/** classId → 班级名(叶子节点,回显名称解析用) */
classById(): Map<number, string> {
const map = new Map<number, string>();
const walk = (ns?: any[] | null) => {
(ns || []).forEach(n => {
if (!(n.children || []).length) map.set(Number(n.id), n.name);
else walk(n.children);
});
};
walk(this.rawTree);
return map;
},
},
created() {
if (this.modelValue.length) {
this.syncFromValue(this.modelValue);
} else {
this.applyAll(); // 新建默认全部班级
}
},
mounted() {
this.loadTree().catch(() => {});
},
methods: {
/** 拉取班级树并缓存;弹窗 loader 与名称解析共用(失败弹窗侧另行报错) */
async loadTree(): Promise<any[]> {
if (this.gradeTree.length) return this.gradeTree;
if (this.treeLoading) {
// 并发调用(mounted 与弹窗同时触发):等首次结果
await new Promise<void>(r => window.setTimeout(r, 200));
if (this.gradeTree.length) return this.gradeTree;
}
this.treeLoading = true;
this.treeFailed = false;
try {
const src = this.primaryCall;
const raw = src ? parseTree((await callModuleApi(src)).body) : [];
this.rawTree = raw;
this.gradeTree = this.toGradeTree(raw);
return this.gradeTree;
} catch (e) {
this.treeFailed = true;
throw e;
} finally {
this.treeLoading = false;
// 回显缓冲:详情先于树就绪时,树加载完成后再应用
if (this.pendingScopes) {
const scoped = this.pendingScopes;
this.pendingScopes = null;
this.applyScopes(scoped);
}
}
},
/** getStuTree →「年级→班级」两层树(ztree 结构):孩子全为叶子的组节点=年级 */
toGradeTree(nodes: any[]): any[] {
const out: any[] = [];
const walk = (ns?: any[] | null) => {
(ns || []).forEach(n => {
const kids = (n.children || []).filter((c: any) => c && c.type !== 2);
if (kids.length && kids.every((c: any) => !(c.children || []).length)) {
out.push({
id: n.id,
name: n.name,
isParent: true,
children: kids.map((c: any) => ({ id: c.id, name: c.name, isParent: false })),
});
} else {
walk(kids);
}
});
};
walk(nodes);
return out;
},
/** 组节点子树内的全部叶子(班级)id */
leafIdsOf(node: any): number[] {
const out: number[] = [];
const walk = (n: any) => {
const kids = (n.children || []).filter((c: any) => c && c.type !== 2);
if (!kids.length) out.push(Number(n.id));
else kids.forEach(walk);
};
walk(node);
return out;
},
/** 全部班级态 */
applyAll() {
this.mode = 0;
this.classItems = [];
this.extras = [];
this.classStr = this.allLabel;
this.emitValue();
},
emitValue() {
const val: ScopeItem[] = this.mode === 0 ? [{ ...ALL_SCOPE, scopeName: this.allLabel }] : [...this.extras, ...this.classItems];
this.suppressWatch = true;
this.$emit("update:modelValue", val);
this.$emit("names", this.mode === 0 ? this.allLabel : this.classStr || "(空)");
this.$nextTick(() => (this.suppressWatch = false));
},
onScopeChange(type: number) {
if (type === 0) {
this.applyAll();
} else {
this.classStr = [...this.extras, ...this.classItems].map(x => x.scopeName).join("、");
this.openPicker();
}
},
openPicker() {
(this.$refs.picker as any)?.show();
},
reset() {
this.classItems = [];
this.classStr = "";
this.emitValue();
},
submit(arr: any[]) {
if (!arr.length) {
// 空选回落全部班级(对齐旧链路 ClassCheckInput 口径)
this.applyAll();
return;
}
this.classItems = arr.map(x => ({ scopeType: 4, scopeId: Number(x.id), scopeName: x.name }));
this.classStr = this.classItems.map(x => x.scopeName).join("、");
this.emitValue();
},
/** 编辑回显:树未就绪时缓冲,就绪后应用(名称解析/学部年级展开依赖树) */
setScopes(scopes?: ScopeItem[] | null) {
const list = scopes || [];
if (!this.rawTree.length && !this.treeFailed) {
this.pendingScopes = list;
if (!this.treeLoading) this.loadTree().catch(() => {});
return;
}
this.applyScopes(list);
},
applyScopes(list: ScopeItem[]) {
const classes: ScopeItem[] = [];
const extras: ScopeItem[] = [];
let school = false;
for (const s of list) {
if (s.scopeType === 1) {
school = true;
} else if (s.scopeType === 4) {
const id = Number(s.scopeId);
classes.push({ scopeType: 4, scopeId: id, scopeName: String(this.classById.get(id) || s.scopeName || `班级${s.scopeId}`) });
} else if (s.scopeType === 2 || s.scopeType === 3) {
// 旧多级面板选的学部/年级:树中展开为下属班级
const node = this.nodeById.get(Number(s.scopeId));
const leafIds = node ? this.leafIdsOf(node) : [];
if (leafIds.length) {
for (const id of leafIds) {
if (!classes.some(c => c.scopeId === id)) {
classes.push({ scopeType: 4, scopeId: id, scopeName: String(this.classById.get(id) || `班级${id}`) });
}
}
} else {
extras.push(s); // 树中已不存在:原样保留防丢范围
}
} else {
extras.push(s); // type 5/6 等:原样保留
}
}
if (school && !classes.length && !extras.length) {
this.applyAll();
} else if (classes.length || extras.length) {
this.mode = 1;
this.classItems = classes;
this.extras = extras;
this.classStr = [...extras, ...classes].map(x => x.scopeName).join("、");
this.emitValue();
} else {
this.applyAll(); // 空范围默认全部班级
}
},
/** 外部赋值同步(不依赖树):按 scopeType 还原 radio 态与名称 */
syncFromValue(list: ScopeItem[]) {
const school = list.some(s => s.scopeType === 1);
const classes = list.filter(s => s.scopeType === 4);
const extras = list.filter(s => s.scopeType !== 1 && s.scopeType !== 4);
if (school && !classes.length && !extras.length) {
this.mode = 0;
this.classItems = [];
this.extras = [];
this.classStr = this.allLabel;
} else if (list.length) {
this.mode = 1;
this.classItems = classes;
this.extras = extras;
this.classStr = [...extras, ...classes].map(x => x.scopeName).join("、");
}
},
},
watch: {
modelValue: {
handler(val: ScopeItem[]) {
// 自身 emit 的变化只跳过状态回灌
if (!this.suppressWatch) this.syncFromValue(val || []);
},
deep: true,
},
},
};
</script>
<style scoped>
.class-scope-wrap {
width: 100%;
}
.class-scope-wrap .check-user-box {
display: flex;
align-items: center;
gap: 8px;
}
.class-scope-wrap .check-user-box .radio {
width: 180px;
flex: none;
font-size: 12px;
vertical-align: middle;
line-height: 32px;
}
.class-scope-wrap .check-user-box .input {
flex: 1;
}
</style>
+1 -28
View File
@@ -36,6 +36,7 @@
*/ */
import UserCheckModal from "@/components/UserCheckModal.vue"; import UserCheckModal from "@/components/UserCheckModal.vue";
import { callModuleApi } from "@/utils/module-session"; import { callModuleApi } from "@/utils/module-session";
import { parseTree } from "@/utils/picker-tree";
import type { BrowserToolCall } from "@/types/agent"; import type { BrowserToolCall } from "@/types/agent";
/** 后端下发的数据源页签(每个 call 都已带模块前缀) */ /** 后端下发的数据源页签(每个 call 都已带模块前缀) */
@@ -44,34 +45,6 @@ interface PickerSource {
call: BrowserToolCall; 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 { export default {
name: "UserCheckInput", name: "UserCheckInput",
components: { UserCheckModal }, components: { UserCheckModal },
+23 -5
View File
@@ -63,7 +63,19 @@ export interface FormField {
* radio「全部用户 / 指定用户」+ 只读回显 + +/清空 + 选人弹窗(左组织树右已选列表)。 * radio「全部用户 / 指定用户」+ 只读回显 + +/清空 + 选人弹窗(左组织树右已选列表)。
* v-model 值是 **字符串**:`"all"` 或用户 id 逗号串。 * v-model 值是 **字符串**:`"all"` 或用户 id 逗号串。
*/ */
| "userpicker"; | "userpicker"
/**
* 「接收班级」自绘控件(校园公告模块页 components/ClassScopeInput.vue 的同款):
* radio「全部班级 / 指定班级」+ 只读回显 + +/清空 + 弹窗班级树(复用选人弹窗)。
* v-model 是 **scope 对象数组**:`[{scopeType,scopeId,scopeName}]`。
*/
| "scopepicker"
/**
* 「接收宿舍」自绘控件(宿舍通知模块页那块「选择接收宿舍」弹窗的同款):
* 左楼栋列表 / 右按楼层分组的房间勾选 / 底部已选间数与住宿生人数。
* v-model 值是 **字符串**:房间 id 逗号串,或全选哨兵(默认 `ALL`)。
*/
| "roompicker";
type?: string; type?: string;
required: boolean; required: boolean;
/** 字段说明(含义与格式) */ /** 字段说明(含义与格式) */
@@ -90,11 +102,17 @@ export interface FormField {
/** widget=richtext:正文内图片的预览地址模板(已带模块前缀,含 {id} 占位) */ /** widget=richtext:正文内图片的预览地址模板(已带模块前缀,含 {id} 占位) */
richTextPreviewPath?: string; richTextPreviewPath?: string;
/** /**
* widget=userpicker:选人弹窗的**数据源页签**(后端下发,每个 call 都已带模块前缀)。 * 自绘选择控件(userpicker / scopepicker / roompicker)的数据源(后端下发,
* 每个 call 都已带模块前缀)。取回的是**原始嵌套树/列表**,不做拍平 —— 拍平会丢掉层级。
* *
* <p>模块页的弹窗有两个页签(「基础平台」组织树 / 「自定义组」); * <p>语义按控件而定:
* 未下发时前端退回 `optionCall`(单页签)。取回的是**原始嵌套树**,不做拍平 —— * <ul>
* 拍平会丢掉层级,树就画不出来。 * <li>{@code userpicker}:**多个页签**(「基础平台」组织树 / 「自定义组」);
* 未下发时前端退回 `optionCall`(单页签)。</li>
* <li>{@code scopepicker}:单页签,班级树(`getStuTree?toUser=false`)。</li>
* <li>{@code roompicker}:**主从两张表**,`[0]` 楼栋列表、`[1]` 房间列表
* (前端切楼栋时自行追加 `?buildingId=`)。</li>
* </ul>
*/ */
pickerSources?: { name: string; call: BrowserToolCall }[]; pickerSources?: { name: string; call: BrowserToolCall }[];
/** 「全部」哨兵值(如 ALL):作为多选的第一项,可选中代表「全部班级 / 全部宿舍」 */ /** 「全部」哨兵值(如 ALL):作为多选的第一项,可选中代表「全部班级 / 全部宿舍」 */
+27
View File
@@ -0,0 +1,27 @@
/**
* 自绘选择控件(userpicker / scopepicker / roompicker)共用的响应解析。
*
* <p>为什么与 `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 [];
}