diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index 3f065e7..733f563 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -15,7 +15,8 @@ */ import { reactive, computed, ref, watch, defineAsyncComponent } from "vue"; import { Message } from "view-ui-plus"; -import { callModuleApi, parseOptionItems, isLoginRedirect, warmModuleSession } from "@/utils/module-session"; +import { callModuleApi, parseOptionGroups, isLoginRedirect, warmModuleSession } from "@/utils/module-session"; +import type { SelectOption, SelectOptionGroup } from "@/utils/module-session"; import UserCheckInput from "@/components/UserCheckInput.vue"; import ScopePickerInput from "@/components/ScopePickerInput.vue"; import RoomPickerInput from "@/components/RoomPickerInput.vue"; @@ -556,7 +557,15 @@ function applyQuickPeriod(f: FormField, item: { start: string; end: string }) { /* ==================== 下拉选项:动态代取 + 联动 ==================== */ /** 动态取回的选项(按字段名);取到后覆盖字段自带的兜底选项 */ -const dynOptions = reactive>({}); +const dynOptions = reactive>({}); +/** + * 动态取回的选项**分组**(仅 `groupByParent` 的字段有值)。 + * + *

家校活动「接收班级」:接口返回「年级 → 班级」两层树,模块页用 OptionGroup + * 渲染成带年级标题的下拉;拍平后高一高二的同名班级就分不出来了。 + * 这里保留父节点名当分组标题,选项本身仍是班级(值不变)。 + */ +const dynGroups = reactive>({}); /** 动态取选项的状态:idle(依赖未填/未开始) · loading · ok · fail */ const optState = reactive>({}); /** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */ @@ -578,6 +587,23 @@ function fieldOptions(f: FormField) { return base; } +/** + * 本字段的下拉**分组**(没有就返回空数组 = 渲染成普通平铺下拉)。 + * + *

对齐模块页的年级分组:``。 + * 「全部」哨兵不进分组 —— 它是个操作(选全部),不是某个年级下的班级, + * 所以渲染时单独摆在最前面(模块页也是在 Select 外面放一个「全部班级」勾选框)。 + */ +function fieldGroups(f: FormField) { + return dynGroups[f.name] || []; +} + +/** 选中标签 / 摘要里显示的文本:分组字段用「年级-班级」,否则就是班级名 */ +function optionText(o: { label: string; fullLabel?: string } | undefined) { + if (!o) return ""; + return o.fullLabel || o.label; +} + /** * 把「对话里已经确定的值」对齐到选项的**取值**上。 * @@ -608,13 +634,19 @@ function alignToOptions(f: FormField, raw: any): any { if (!arr.length) return raw; if (!multi && String(raw).trim() === "") return raw; const authoritative = !f.optionCall || optState[f.name] === "ok"; + // 标签比对的「松」口径:去掉分隔符(「高一-一班」与「高一一班」应当认成同一个)。 + // 注意只对**带年级的全名**有意义 —— 裸的「一班」在两个年级都存在,仍然按「多义」处理不认。 + const loose = (t: string) => t.replace(/[\s\-()()]/g, ""); const out: any[] = []; for (const v of arr) { if (v === undefined || v === null) continue; const s = String(v); const byValue = opts.find(o => o.value === s); - const byLabel = opts.filter(o => o.label === s); - const hit = byValue || (byLabel.length === 1 ? byLabel[0] : undefined); + const byLabel = opts.filter(o => o.label === s || o.fullLabel === s); + const byLooseHit = byLabel.length + ? byLabel + : opts.filter(o => loose(o.label) === loose(s) || (!!o.fullLabel && loose(o.fullLabel) === loose(s))); + const hit = byValue || (byLooseHit.length === 1 ? byLooseHit[0] : undefined); if (hit) out.push(hit.value); else if (!authoritative) out.push(s); // 兜底项不全,原样留着,别误伤自定义项 } @@ -684,6 +716,7 @@ async function loadOptions(f: FormField, force = false) { if (!call) return; if (!depsReady(f)) { delete dynOptions[f.name]; + delete dynGroups[f.name]; optState[f.name] = "idle"; return; } @@ -693,9 +726,17 @@ async function loadOptions(f: FormField, force = false) { pathAndQuery = pathAndQuery.split(`{{${d}}}`).join(encodeURIComponent(String(values[d]))); } const res = await callModuleApi({ ...call, pathAndQuery }, undefined, { forceWarm: force }); - const items = res.error ? [] : parseOptionItems(res.body, f.valueKey, f.labelKey); + // 分组字段(家校活动「接收班级」)必须留住父节点名:接口返回的是「年级 → 班级」两层树, + // 只拍平会让高一 / 高二的「一班」在下拉里长得一模一样(用户真实反馈)。 + // 非分组字段走老路(拍平),行为完全不变。 + const parsed = res.error + ? { items: [] as SelectOption[], groups: [] as SelectOptionGroup[] } + : parseOptionGroups(res.body, f.valueKey, f.labelKey, !!f.groupByParent); + const items = parsed.items; if (items.length) { dynOptions[f.name] = items; + if (parsed.groups.length) dynGroups[f.name] = parsed.groups; + else delete dynGroups[f.name]; optState[f.name] = "ok"; // ★ 实时选项到手后把手上的值再对齐一次 —— 这才是「初值能不能认出来」的真正时刻: // 预填的值可能是数字(integer 参数)或名称(string 参数), @@ -725,6 +766,7 @@ async function loadOptions(f: FormField, force = false) { } } else { dynOptions[f.name] = []; + delete dynGroups[f.name]; optState[f.name] = "fail"; // 取不到选项就不能藏 —— 否则用户既看不到控件也没值,必填校验直接卡死 delete hiddenSingle[f.name]; @@ -764,6 +806,7 @@ function syncOptions() { function resetOptionState() { Object.keys(dynOptions).forEach(k => delete dynOptions[k]); + Object.keys(dynGroups).forEach(k => delete dynGroups[k]); Object.keys(optState).forEach(k => delete optState[k]); Object.keys(depSig).forEach(k => delete depSig[k]); } @@ -1158,7 +1201,8 @@ function onSubmit() { const normalized = normalize(f, v); args[f.name] = normalized; const opt = fieldOptions(f).find(o => String(o.value) === String(normalized)); - rows.push({ label: f.label, text: opt ? opt.label : String(normalized) }); + // 摘要用 optionText:分组字段(接收班级)显示「年级-班级」,否则同名班级在摘要里也认不出年级 + rows.push({ label: f.label, text: opt ? optionText(opt) : String(normalized) }); } // 选了「提交审批」却没配审批人 → 与模块页一致地拦下来(后端也会拒) if (args.publishType === "approve" && !(Array.isArray(args.flow) && args.flow.length)) { @@ -1281,7 +1325,15 @@ function reset() { :filterable="(f.dependsOn || []).length > 0" clearable > - + + +

diff --git a/src/types/agent.ts b/src/types/agent.ts index e4448f3..7515ed6 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -162,6 +162,14 @@ export interface FormField { * `quota: form.receiptMode === "none" ? 0 : ...`)→ 后端给 `hiddenValue: 0`。 */ hiddenValue?: any; + /** + * 下拉选项按**父节点**分组(如「接收班级」按年级分组)。 + * + * 选项来源接口返回的是「年级 → 班级」两层树(家校活动 `GetClassTree`), + * 模块页用 `` 渲染;拍平后高一高二的「一班」长得一模一样。 + * 标注后本组件:① 按父节点名分组渲染;② 选中标签与提交摘要显示「年级-班级」。 + */ + groupByParent?: boolean; /** * 快捷时段按钮组:`{ start, end, items: [{label,start,end}] }`。 * `start/end` 是**时刻**(HH:mm:ss),点按钮时套用到当前已选日期上。 diff --git a/src/utils/module-session.ts b/src/utils/module-session.ts index 3f9521e..46e2382 100644 --- a/src/utils/module-session.ts +++ b/src/utils/module-session.ts @@ -254,8 +254,109 @@ export async function callModuleApi( } } +/** 表单下拉项。`fullLabel` 只在「按父节点分组」时才有(= 组名-项名,供选中标签/摘要区分同名项) */ +export interface SelectOption { + value: string; + label: string; + fullLabel?: string; +} + +/** 下拉分组(如年级标题 + 该年级下的班级) */ +export interface SelectOptionGroup { + label: string; + options: SelectOption[]; +} + +/** 把一条原始记录按后端给的 valueKey/labelKey 取成下拉项(labelKey 支持 `+` 拼多字段) */ +function toOption(item: any, vk: string, lk: string): SelectOption | null { + if (item == null || typeof item !== "object") return null; + const v = item[vk]; + if (v === undefined || v === null || String(v) === "") return null; + // labelKey 支持用「+」拼多个字段(如房间的 buildingName+roomNo →「1号楼 305」), + // 因为这类 VO 里没有现成的展示名,靠单一字段会出现「同名房间分不清楼栋」。 + const l = lk.includes("+") + ? lk.split("+").map(k => item[k.trim()]).filter(V => V !== undefined && V !== null && String(V) !== "").join(" ") + : item[lk]; + return { + value: String(v), + label: l === undefined || l === null || String(l) === "" ? String(v) : String(l), + }; +} + /** - * 把「字典接口原始响应」解析成表单下拉项。 + * 把「字典接口原始响应」解析成**带分组**的下拉项(分组标题 + 叶子选项)。 + * + *

为什么不能只拍平(真实反馈:「接收班级」下拉里只有「一班 / 二班」, + * 分不清哪个高一哪个高二):家校活动模块页的班级多选是 + * ``,接口返回的是**年级 → 班级**两层树 —— + * 只有把父节点名留住,才画得出分组标题。 + * + * @param grouped 是否按父节点分组。为 true 时额外的两件事: + * ① 每个叶子带上 `fullLabel = 组名-项名`(模块页的 `:label="${g.grade}-${c.label}"`, + * 供**选中标签与提交摘要**用 —— 同名班级只凭班级名认不出来); + * ② 每个顶层节点归成一个 `groups` 条目。为 false 时 `groups` 为空,行为与旧版完全一致。 + */ +export function parseOptionGroups( + body?: string, + valueKey?: string, + labelKey?: string, + grouped = false +): { items: SelectOption[]; groups: SelectOptionGroup[] } { + const empty = { items: [] as SelectOption[], groups: [] as SelectOptionGroup[] }; + if (!body) return empty; + let payload: any; + try { + payload = JSON.parse(body); + } catch { + return empty; + } + if (payload == null) return empty; + // 平台统一包装 {code,msg,data}:非 0 即业务失败 + if (typeof payload === "object" && "code" in payload) { + if (Number(payload.code) !== 0) return empty; + payload = payload.data; + } + const arr = findArray(payload, 0); + if (!arr) return empty; + + const vk = valueKey || "id"; + const lk = labelKey || "name"; + // 组织树(年级→班级 / 学部→年级→班级)要看是不是树: + // 任一项带非空 children 数组即视为树;否则按平铺字典处理(多数字典接口是平铺的)。 + const isTree = arr.some(it => it && Array.isArray(it.children) && it.children.length); + + if (!grouped || !isTree) { + const items = flattenLeaves(arr).map(it => toOption(it, vk, lk)).filter(Boolean) as SelectOption[]; + return { items, groups: [] }; + } + + // 按父节点分组:顶层节点名当组标题,其(可能多层的)叶子当选项 + const items: SelectOption[] = []; + const groups: SelectOptionGroup[] = []; + for (const node of arr) { + if (node == null || typeof node !== "object") continue; + const kids = Array.isArray(node.children) ? node.children : []; + const opts: SelectOption[] = []; + for (const leaf of flattenLeaves(kids)) { + const o = toOption(leaf, vk, lk); + if (!o) continue; + const gname = (() => { + const g = node[lk]; + return g === undefined || g === null || String(g) === "" ? "" : String(g); + })(); + // 组名拼进 fullLabel 用的是同一个 labelKey(module 页也是 `g.grade` 与 `c.name` 同源) + if (gname) o.fullLabel = `${gname}-${o.label}`; + opts.push(o); + } + if (!opts.length) continue; + items.push(...opts); + groups.push({ label: String(node[lk] ?? ""), options: opts }); + } + return { items, groups }; +} + +/** + * 把「字典接口原始响应」解析成**平铺的**表单下拉项(不分组)。 * * 解析是**确定性**的:由后端在字段定义里显式给出 {@code valueKey / labelKey} * (如 `classId/className`),前端只按这两个键取值,不猜结构。 @@ -264,46 +365,8 @@ export function parseOptionItems( body?: string, valueKey?: string, labelKey?: string -): { value: string; label: string }[] { - if (!body) return []; - let payload: any; - try { - payload = JSON.parse(body); - } catch { - return []; - } - if (payload == null) return []; - // 平台统一包装 {code,msg,data}:非 0 即业务失败 - if (typeof payload === "object" && "code" in payload) { - if (Number(payload.code) !== 0) return []; - payload = payload.data; - } - const arr = findArray(payload, 0); - if (!arr) return []; - - const vk = valueKey || "id"; - const lk = labelKey || "name"; - const out: { value: string; label: string }[] = []; - // 组织树(年级→班级 / 学部→年级→班级)要拍平成叶子,否则下拉里只会出现「年级」这一层的 id。 - // 判定:任一项带非空 children 数组即视为树;否则按平铺字典处理(多数字典接口是平铺的)。 - const flat = arr.some(it => it && Array.isArray((it as any).children) && (it as any).children.length) - ? flattenLeaves(arr) - : arr; - for (const item of flat) { - if (item == null || typeof item !== "object") continue; - const v = (item as any)[vk]; - if (v === undefined || v === null || String(v) === "") continue; - // labelKey 支持用「+」拼多个字段(如房间的 buildingName+roomNo →「1号楼 305」), - // 因为这类 VO 里没有现成的展示名,靠单一字段会出现「同名房间分不清楼栋」。 - const l = lk.includes("+") - ? lk.split("+").map(k => (item as any)[k.trim()]).filter(V => V !== undefined && V !== null && String(V) !== "").join(" ") - : (item as any)[lk]; - out.push({ - value: String(v), - label: l === undefined || l === null || String(l) === "" ? String(v) : String(l), - }); - } - return out; +): SelectOption[] { + return parseOptionGroups(body, valueKey, labelKey, false).items; } /** 把「组→子」的树拍平成叶子列表(叶子 = 没有非空 children 的节点) */