fix(表单): 对话里定好的值要对齐选项取值,别再让用户选一遍
build / build (push) Successful in 1m53s

「所属栏目」这类下拉的选项值一律是字符串,而对话里定下来的值走的是另一条路:
integer 参数模型给的是数字 71,string 参数模型很可能直接给名称(年级段「初中」、
学科「数学」)。Select / Radio 的选中态是严格相等比较,于是预填了也选不上、
下拉照旧显示「请选择」—— 用户看到的就是「刚问过我,表单里又让我选一遍」。

在 FormCard 里补一步「对齐选项取值」:先按值匹配(String 后比),再按 label 反查
(唯一命中才认,避免同名歧义)。匹配不上时**不无条件清空**:带 optionCall 的字段
在下发时可能只有内置兜底项(如请假类型的内置「病假 / 事假及其他」,学校的自定义
「调休」要等实时字典),此时认不出只是「暂时」——若清空,实时选项回来后会被
autoPickFirst 顺手改成第一项,那是悄悄改坏数据。只有选项权威(无代取通道,或
实时选项已取回)才丢弃认不出的值,让必填校验老老实实要求用户重选。

同时把 mock 夹具的形态对齐真实后端:专业发展的 detect 早先给字符串 "71",
而工具 schema 声明的是 integer(模型给数字 71),于是「数字 71 与选项字符串 "71"
严格比较不相等」这个坑本地永远测不出来(第 5 次夹具失真)。
This commit is contained in:
lijing
2026-10-14 11:35:00 +08:00
parent 4e6725aa1b
commit 86e3a10a06
+65 -2
View File
@@ -512,6 +512,60 @@ function fieldOptions(f: FormField) {
return base; 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) { function depsReady(f: FormField) {
return (f.dependsOn || []).every(d => { return (f.dependsOn || []).every(d => {
@@ -575,6 +629,12 @@ async function loadOptions(f: FormField, force = false) {
if (items.length) { if (items.length) {
dynOptions[f.name] = items; dynOptions[f.name] = items;
optState[f.name] = "ok"; 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 cur = values[f.name];
const empty = cur === undefined || cur === null || String(cur).trim() === ""; const empty = cur === undefined || cur === null || String(cur).trim() === "";
// ★ 值一律按选项本身的类型(字符串)存:Radio/Select 都是 `选中态 === label` 的**严格**比较, // ★ 值一律按选项本身的类型(字符串)存:Radio/Select 都是 `选中态 === label` 的**严格**比较,
@@ -684,11 +744,14 @@ function initValues() {
} else if (f.widget === "multiselect" || f.widget === "scopes") { } else if (f.widget === "multiselect" || f.widget === "scopes") {
const arr = Array.isArray(seed) ? [...seed] : seed ? [seed] : []; 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") { } else if (f.widget === "file") {
values[f.name] = Array.isArray(seed) ? [...seed] : []; values[f.name] = Array.isArray(seed) ? [...seed] : [];
} else if (f.widget === "select" || f.widget === "number" || f.widget === "radio") { } 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 { } else {
values[f.name] = seed ?? ""; values[f.name] = seed ?? "";
} }