承接上一提交(通知公告的两个控件),把其余模块里同类控件一次搬到位。
新增组件(都照搬对应模块页的实现,只把「数据从哪来」换成后端下发的 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:
+91
-19
@@ -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),
|
||||
* **不是**下拉字典。凡是「取选项 / 拍平 / 降级成下拉」的地方都要绕开它们。
|
||||
*
|
||||
* <p>userpicker 通知对象 · scopepicker 接收班级 · roompicker 接收宿舍。
|
||||
*/
|
||||
function isSelfDrawnPicker(f: FormField): boolean {
|
||||
return f.widget === "userpicker" || f.widget === "scopepicker" || f.widget === "roompicker";
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本正文编辑器(wangEditor)——**异步加载**。
|
||||
*
|
||||
* <p>`@wangeditor/*` 是重量级依赖(编辑器 + 工具栏 + 样式)。整个助手只有「通知公告」
|
||||
* 的发布表单用得上,静态引入等于让所有用户都背上这份体积,所以拆包按需加载。
|
||||
* <p>`@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) ==================== */
|
||||
/**
|
||||
* 「通知对象」的显示文本(按字段名)。
|
||||
* 自绘控件显示文本(按字段名)。
|
||||
*
|
||||
* <p>为什么要在外面存一份:控件内部只持有 id 串(`"101,102"`),提交后只读摘要里
|
||||
* 直接摆一串 id 是没法看的。控件在选中/清空时会 `@names` 回传它自己的可读文本
|
||||
* (如「张三、李四」),这里接住用于摘要。
|
||||
* <p>为什么要在外面存一份:控件内部只持有 id 串 / id 串(`"101,102"`),提交后只读摘要里
|
||||
* 直接摆一串 id 是没法看的。各控件在选中/清空时会 `@names` 回传它自己的可读文本
|
||||
* (如「张三、李四」/「二年级(1)班」/「3 间宿舍 · 12 名住宿生|1 号楼(101、102)」),
|
||||
* 这里接住用于摘要。
|
||||
*/
|
||||
const pickerText = reactive<Record<string, string>>({});
|
||||
|
||||
/** 选人弹窗的页签(后端下发;没有时退回 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() {
|
||||
/>
|
||||
</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 字符串) -->
|
||||
<div v-else-if="widgetOf(f) === 'richtext'" class="field__richtext">
|
||||
<RichTextEditor
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
* <p>上传同样带**用户自己的登录态**:模块接口只认模块容器的 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<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}` 换掉) */
|
||||
function previewUrl(id: any): string {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把模块返回的原始响应解成**组织树数组**。
|
||||
*
|
||||
* <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 },
|
||||
|
||||
+23
-5
@@ -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 都已带模块前缀)。取回的是**原始嵌套树/列表**,不做拍平 —— 拍平会丢掉层级。
|
||||
*
|
||||
* <p>模块页的弹窗有两个页签(「基础平台」组织树 / 「自定义组」);
|
||||
* 未下发时前端退回 `optionCall`(单页签)。取回的是**原始嵌套树**,不做拍平 ——
|
||||
* 拍平会丢掉层级,树就画不出来。
|
||||
* <p>语义按控件而定:
|
||||
* <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 }[];
|
||||
/** 「全部」哨兵值(如 ALL):作为多选的第一项,可选中代表「全部班级 / 全部宿舍」 */
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
Reference in New Issue
Block a user