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 ?? ""; }