From 86e3a10a0637b9ecd629963bfbe70f3ba9a7baa7 Mon Sep 17 00:00:00 2001 From: lijing Date: Wed, 14 Oct 2026 11:35:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=A1=A8=E5=8D=95):=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E9=87=8C=E5=AE=9A=E5=A5=BD=E7=9A=84=E5=80=BC=E8=A6=81=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E9=80=89=E9=A1=B9=E5=8F=96=E5=80=BC=EF=BC=8C=E5=88=AB?= =?UTF-8?q?=E5=86=8D=E8=AE=A9=E7=94=A8=E6=88=B7=E9=80=89=E4=B8=80=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「所属栏目」这类下拉的选项值一律是字符串,而对话里定下来的值走的是另一条路: integer 参数模型给的是数字 71,string 参数模型很可能直接给名称(年级段「初中」、 学科「数学」)。Select / Radio 的选中态是严格相等比较,于是预填了也选不上、 下拉照旧显示「请选择」—— 用户看到的就是「刚问过我,表单里又让我选一遍」。 在 FormCard 里补一步「对齐选项取值」:先按值匹配(String 后比),再按 label 反查 (唯一命中才认,避免同名歧义)。匹配不上时**不无条件清空**:带 optionCall 的字段 在下发时可能只有内置兜底项(如请假类型的内置「病假 / 事假及其他」,学校的自定义 「调休」要等实时字典),此时认不出只是「暂时」——若清空,实时选项回来后会被 autoPickFirst 顺手改成第一项,那是悄悄改坏数据。只有选项权威(无代取通道,或 实时选项已取回)才丢弃认不出的值,让必填校验老老实实要求用户重选。 同时把 mock 夹具的形态对齐真实后端:专业发展的 detect 早先给字符串 "71", 而工具 schema 声明的是 integer(模型给数字 71),于是「数字 71 与选项字符串 "71" 严格比较不相等」这个坑本地永远测不出来(第 5 次夹具失真)。 --- src/components/FormCard.vue | 67 +++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/components/FormCard.vue b/src/components/FormCard.vue index 55c85b2..5c236f1 100644 --- a/src/components/FormCard.vue +++ b/src/components/FormCard.vue @@ -512,6 +512,60 @@ function fieldOptions(f: FormField) { return base; } +/** + * 把「对话里已经确定的值」对齐到选项的**取值**上。 + * + * ★ 为什么必须有这一步(真实反馈:「所属栏目刚问过又让我选一遍」): + * Select / Radio 的选中态是**严格相等**比较(view-ui-plus 的 `value === option.value`), + * 而选项值一律是**字符串**(见 `parseOptionItems` 与 `loadOptions` 里的说明)。可对话里 + * 定下来的值走的是两条完全不同的路: + * - 参数声明成 `integer` 的(「所属栏目」moduleId)⇒ 模型按声明给的是**数字** 71; + * - 参数声明成 `string` 的(「年级段」「学科」)⇒ 模型很可能直接给**名称**「初中」「数学」。 + * 两者与选项的 `"71"` / `"2"` / `"SUB02"` 都对不上,于是下拉照旧显示「请选择」—— + * 用户看到的就是「刚问过我,表单里又让我选」。 + * + * 处理口径:先按值匹配(`String` 后比),再按 label 反查(唯一命中才认,避免同名歧义)。 + * + * ⚠️ 匹配不上时**不能无条件清空**:带 `optionCall` 的字段在下发时可能只有**内置兜底项** + * (如请假类型的内置「病假 / 事假及其他」,学校的自定义「调休」要等前端取回实时字典), + * 兜底项里找不到的值只是「暂时认不出」。若此时清空,`loadOptions` 拿回真选项后又会被 + * `autoPickFirst` 顺手改成第一项 —— 那是悄悄改坏数据,比不选还糟。 + * 因此只有选项**权威**时(无代取通道,或实时选项已取回)才丢弃认不出的值, + * 让必填校验老老实实要求用户重选。 + */ +function alignToOptions(f: FormField, raw: any): any { + if (raw === undefined || raw === null) return raw; + const opts = fieldOptions(f); + if (!opts.length) return raw; + const multi = f.widget === "multiselect" || f.widget === "scopes"; + const arr: any[] = multi ? (Array.isArray(raw) ? raw : [raw]) : [raw]; + if (!arr.length) return raw; + if (!multi && String(raw).trim() === "") return raw; + const authoritative = !f.optionCall || optState[f.name] === "ok"; + 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); + if (hit) out.push(hit.value); + else if (!authoritative) out.push(s); // 兜底项不全,原样留着,别误伤自定义项 + } + if (multi) return out; + return out.length ? out[0] : undefined; +} + +/** 两个控件值是否等价(数组按逐项 String 比)——用于「值没变就不动它」,免得白触发一轮响应式 */ +function sameVal(a: any, b: any) { + if (Array.isArray(a) || Array.isArray(b)) { + const x = Array.isArray(a) ? a : []; + const y = Array.isArray(b) ? b : []; + return x.length === y.length && x.every((v, i) => String(v) === String(y[i])); + } + return String(a ?? "") === String(b ?? ""); +} + /** 依赖字段是否都已填好 —— 没填好就不该去拉从属选项,也不该让用户点开一个空下拉 */ function depsReady(f: FormField) { return (f.dependsOn || []).every(d => { @@ -575,6 +629,12 @@ async function loadOptions(f: FormField, force = false) { if (items.length) { dynOptions[f.name] = items; optState[f.name] = "ok"; + // ★ 实时选项到手后把手上的值再对齐一次 —— 这才是「初值能不能认出来」的真正时刻: + // 预填的值可能是数字(integer 参数)或名称(string 参数), + // 选项没到位之前根本无从判断,只能等这里。optState 已置 ok ⇒ 选项权威, + // 认不出的值会被清掉(交给必填校验),不会再出现「值在、界面却是空的」。 + const aligned = alignToOptions(f, values[f.name]); + if (!sameVal(aligned, values[f.name])) values[f.name] = aligned; const cur = values[f.name]; const empty = cur === undefined || cur === null || String(cur).trim() === ""; // ★ 值一律按选项本身的类型(字符串)存:Radio/Select 都是 `选中态 === label` 的**严格**比较, @@ -684,11 +744,14 @@ function initValues() { } else if (f.widget === "multiselect" || f.widget === "scopes") { const arr = Array.isArray(seed) ? [...seed] : seed ? [seed] : []; // 接收对象默认「全部」(与模块页的默认口径一致);用户可取消后另选具体项 - values[f.name] = arr.length ? arr : f.allToken ? [f.allToken] : []; + // ★ 对齐选项取值:多选控件的选中态同样是严格比较,`[71]` 对不上选项的 `"71"` + values[f.name] = alignToOptions(f, arr.length ? arr : f.allToken ? [f.allToken] : []); } else if (f.widget === "file") { values[f.name] = Array.isArray(seed) ? [...seed] : []; } else if (f.widget === "select" || f.widget === "number" || f.widget === "radio") { - values[f.name] = seed ?? undefined; + // ★ 对齐选项取值:模型按 schema 给的是数字(integer)或名称(string), + // 这里统一成「选项本身的值」,否则预填了也选不上,用户以为要再选一次 + values[f.name] = seed === undefined || seed === null ? undefined : alignToOptions(f, seed); } else { values[f.name] = seed ?? ""; }