feat(表单): 下拉支持「按父节点分组」,选中标签与摘要带年级
build / build (push) Successful in 2m40s

- utils/module-session.ts 新增 parseOptionGroups(body, valueKey, labelKey, grouped):
  分组时保留父节点名,叶子带 fullLabel(组名-项名);parseOptionItems 改为其薄封装,
  不分组时行为与原先完全一致
- components/FormCard.vue:multiselect / select 两个分支用 <OptionGroup> 渲染分组
  (「全部」哨兵不进分组,仍单独摆最前);选中标签与提交摘要统一走 optionText();
  alignToOptions 加松匹配(认「高一一班」=「高一-一班」,但裸「一班」多义仍不认)
- types/agent.ts 补 groupByParent 类型

注:view-ui-plus 的 Option 用 :label prop 作选中标签、slot 作下拉项文本,
故分组之外标签也必须带年级(与模块页 `:label="${年级}-${班级}"` 一致)。

验证:npm run build 通过;真机 activity 隐藏档 65/65、可见档 63/63,
实测下拉分组标题为「高一 / 高二」,选中标签为「高一-一班 / 高二-一班」。
This commit is contained in:
lijing
2026-10-15 17:00:41 +08:00
parent 41aa77531a
commit 1ee6b0c702
3 changed files with 186 additions and 49 deletions
+74 -8
View File
@@ -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<Record<string, { value: string; label: string }[]>>({});
const dynOptions = reactive<Record<string, SelectOption[]>>({});
/**
* 动态取回的选项**分组**(仅 `groupByParent` 的字段有值)。
*
* <p>家校活动「接收班级」:接口返回「年级 → 班级」两层树,模块页用 OptionGroup
* 渲染成带年级标题的下拉;拍平后高一高二的同名班级就分不出来了。
* 这里保留父节点名当分组标题,选项本身仍是班级(值不变)。
*/
const dynGroups = reactive<Record<string, SelectOptionGroup[]>>({});
/** 动态取选项的状态:idle(依赖未填/未开始) · loading · ok · fail */
const optState = reactive<Record<string, "idle" | "loading" | "ok" | "fail">>({});
/** 依赖字段值指纹:只有依赖真的变了才重新拉取,避免每次输入都打接口 */
@@ -578,6 +587,23 @@ function fieldOptions(f: FormField) {
return base;
}
/**
* 本字段的下拉**分组**(没有就返回空数组 = 渲染成普通平铺下拉)。
*
* <p>对齐模块页的年级分组:`<OptionGroup :label="g.grade">`。
* 「全部」哨兵不进分组 —— 它是个操作(选全部),不是某个年级下的班级,
* 所以渲染时单独摆在最前面(模块页也是在 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
>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
<!-- 分组下拉:同 multiselect 分支(父节点名当组标题,选中标签带父节点名) -->
<template v-if="fieldGroups(f).length">
<OptionGroup v-for="g in fieldGroups(f)" :key="g.label" :label="g.label">
<Option v-for="o in g.options" :key="o.value" :value="o.value" :label="optionText(o)">{{ o.label }}</Option>
</OptionGroup>
</template>
<template v-else>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</template>
</Select>
<Select
@@ -1292,7 +1344,21 @@ function reset() {
:disabled="disabled || !depsReady(f) || optState[f.name] === 'loading'"
:loading="optState[f.name] === 'loading'"
>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
<!-- 分组下拉:对齐模块页的班级多选(`<OptionGroup :label="g.grade">`)。
★ 选中标签用 `:label`,取「年级-班级」—— 高一和高二都有「一班」,
只凭班级名认不出年级;模块页也是 :label="`${g.grade}-${c.label}`"。
「全部」哨兵不归任何年级,单独摆在最前面(模块页也是在 Select 外面放勾选框)。 -->
<template v-if="fieldGroups(f).length">
<Option v-if="f.allToken" :value="f.allToken" :label="f.allTokenLabel || '全部'">
{{ f.allTokenLabel || "全部" }}
</Option>
<OptionGroup v-for="g in fieldGroups(f)" :key="g.label" :label="g.label">
<Option v-for="o in g.options" :key="o.value" :value="o.value" :label="optionText(o)">{{ o.label }}</Option>
</OptionGroup>
</template>
<template v-else>
<Option v-for="o in fieldOptions(f)" :key="o.value" :value="o.value">{{ o.label }}</Option>
</template>
</Select>
<div v-else-if="widgetOf(f) === 'cascader'" class="field__cascader">
+8
View File
@@ -162,6 +162,14 @@ export interface FormField {
* `quota: form.receiptMode === "none" ? 0 : ...`)→ 后端给 `hiddenValue: 0`。
*/
hiddenValue?: any;
/**
* 下拉选项按**父节点**分组(如「接收班级」按年级分组)。
*
* 选项来源接口返回的是「年级 → 班级」两层树(家校活动 `GetClassTree`),
* 模块页用 `<OptionGroup :label="g.grade">` 渲染;拍平后高一高二的「一班」长得一模一样。
* 标注后本组件:① 按父节点名分组渲染;② 选中标签与提交摘要显示「年级-班级」。
*/
groupByParent?: boolean;
/**
* 快捷时段按钮组:`{ start, end, items: [{label,start,end}] }`。
* `start/end` 是**时刻**(HH:mm:ss),点按钮时套用到当前已选日期上。
+104 -41
View File
@@ -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),
};
}
/**
* 把「字典接口原始响应」解析成表单下拉项。
* 把「字典接口原始响应」解析成**带分组**的下拉项(分组标题 + 叶子选项)。
*
* <p>为什么不能只拍平(真实反馈:「接收班级」下拉里只有「一班 / 二班」,
* 分不清哪个高一哪个高二):家校活动模块页的班级多选是
* `<OptionGroup :label="g.grade">`,接口返回的是**年级 → 班级**两层树 ——
* 只有把父节点名留住,才画得出分组标题。
*
* @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 的节点) */