NewLife/NewLife.Cube.React

字段名匹配全面大小写不敏感:getValueByKey/setValueByKey + 各消费点

- getValueByKey 改为全量大小写不敏感比对(for...in 按小写匹配),兼容注入扩展字段键(IExtend.Items PascalCase)与实体 JSON camelCase 并存
- 新增 setValueByKey(大小写不敏感写入),FormDialog/FormPage 主键合并改用它
- serializeSubmitModel 多选字段名大小写不敏感;ConfigPage 配置值按字段名大小写不敏感回填(修复后端 camelCase 键与 PascalCase 字段名对不上)
- TableContent 软删除字段、useLovOptions/LovSelectTable 行取值改 getValueByKey
- 单测补充:多字符大小写差异、setValueByKey、serializeSubmitModel 大小写、resolveUrl 占位符大小写
大石头 authored at 2026-08-31 11:35:10
aa4a298
Tree
1 Parent(s) 894626a
Summary: 10 changed files with 112 additions and 31 deletions.
Modified +4 -3
Modified +3 -2
Modified +6 -0
Modified +51 -2
Modified +2 -2
Modified +33 -12
Modified +7 -4
Modified +3 -3
Modified +2 -2
Modified +1 -1
Modified +4 -3
diff --git a/NewLife.Cube.React/web/src/components/field/LovSelectTable.tsx b/NewLife.Cube.React/web/src/components/field/LovSelectTable.tsx
index 3246406..6a80bf0 100644
--- a/NewLife.Cube.React/web/src/components/field/LovSelectTable.tsx
+++ b/NewLife.Cube.React/web/src/components/field/LovSelectTable.tsx
@@ -13,6 +13,7 @@ import { SearchOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 import { fetchLovMeta, fetchLovListData } from '@/api/lov';
 import { resolveLovType } from '@/types/lov';
+import { getValueByKey } from '@/utils/url';
 import type { LovListMeta, LovSearchField } from '@/types/lov';
 
 export interface LovSelectTableProps {
@@ -164,7 +165,7 @@ export default function LovSelectTable({
   // 单选行点击即选并关闭
   const handleRowClick = (row: Record<string, unknown>) => {
     if (multiple) return;
-    const key = String(row[vf] ?? '');
+    const key = String(getValueByKey(row, vf) ?? '');
     if (key) {
       onChange?.(key);
       setOpen(false);
@@ -199,7 +200,7 @@ export default function LovSelectTable({
                   height: 14,
                   borderRadius: '50%',
                   border: '1px solid #d9d9d9',
-                  background: selectedKeys.includes(String(row[vf])) ? '#1677ff' : '#fff',
+                  background: selectedKeys.includes(String(getValueByKey(row, vf) ?? '')) ? '#1677ff' : '#fff',
                 }}
               />
             }
@@ -283,7 +284,7 @@ export default function LovSelectTable({
         )}
         {/* 数据表格 */}
         <Table
-          rowKey={(row) => String(row[vf] ?? '')}
+          rowKey={(row) => String(getValueByKey(row, vf) ?? '')}
           size="small"
           loading={loading}
           columns={columns}
Modified +3 -2
diff --git a/NewLife.Cube.React/web/src/hooks/useLovOptions.ts b/NewLife.Cube.React/web/src/hooks/useLovOptions.ts
index 48592ad..c648c14 100644
--- a/NewLife.Cube.React/web/src/hooks/useLovOptions.ts
+++ b/NewLife.Cube.React/web/src/hooks/useLovOptions.ts
@@ -9,6 +9,7 @@
 import { useEffect, useState } from 'react';
 import { fetchLovMeta, fetchLovListData } from '@/api/lov';
 import { resolveLovType } from '@/types/lov';
+import { getValueByKey } from '@/utils/url';
 
 export interface LovOption {
   value: string;
@@ -62,8 +63,8 @@ export function useLovOptions(lovCode?: string, dataSource?: Record<string, stri
           if (!cancelled) {
             setOptions(
               res.data.map((row) => ({
-                value: String(row[vf] ?? ''),
-                label: String(row[lf] ?? ''),
+                value: String(getValueByKey(row, vf) ?? ''),
+                label: String(getValueByKey(row, lf) ?? ''),
               })),
             );
           }
Modified +6 -0
diff --git a/NewLife.Cube.React/web/src/utils/__tests__/fieldControl.test.ts b/NewLife.Cube.React/web/src/utils/__tests__/fieldControl.test.ts
index ddd83b1..efd3340 100644
--- a/NewLife.Cube.React/web/src/utils/__tests__/fieldControl.test.ts
+++ b/NewLife.Cube.React/web/src/utils/__tests__/fieldControl.test.ts
@@ -131,6 +131,12 @@ describe('serializeSubmitModel 提交序列化', () => {
     const out = serializeSubmitModel({ Tags: 'a', Num: 3 }, fields);
     expect(out).toEqual({ Tags: 'a', Num: 3 });
   });
+
+  it('表单键与字段名大小写不一致时仍合并多选(大小写不敏感)', () => {
+    const fields = [{ name: 'RoleIds', multiple: true, typeName: 'String' }] as FieldMeta[];
+    const out = serializeSubmitModel({ roleIds: ['1', '2'] }, fields);
+    expect(out).toEqual({ roleIds: '1,2' });
+  });
 });
 
 describe('mapDataToFormValues 编辑回填映射', () => {
Modified +51 -2
diff --git a/NewLife.Cube.React/web/src/utils/__tests__/url.test.ts b/NewLife.Cube.React/web/src/utils/__tests__/url.test.ts
index b59abe8..ef3b80f 100644
--- a/NewLife.Cube.React/web/src/utils/__tests__/url.test.ts
+++ b/NewLife.Cube.React/web/src/utils/__tests__/url.test.ts
@@ -2,7 +2,15 @@
  * URL/取值工具单元测试
  */
 import { describe, expect, it } from 'vitest';
-import { toPascalCase, toCamelCase, toPascalAndCamel, routeToApiPrefix, getValueByKey, resolveUrl } from '@/utils/url';
+import {
+  toPascalCase,
+  toCamelCase,
+  toPascalAndCamel,
+  routeToApiPrefix,
+  getValueByKey,
+  setValueByKey,
+  resolveUrl,
+} from '@/utils/url';
 
 describe('toPascalCase / toCamelCase', () => {
   it('基本转换', () => {
@@ -21,7 +29,7 @@ describe('routeToApiPrefix', () => {
   });
 });
 
-describe('getValueByKey 大小写容错', () => {
+describe('getValueByKey 大小写不敏感', () => {
   const row = { id: 1, Name: '张三', mobile: '138' };
 
   it('直接命中', () => {
@@ -40,14 +48,55 @@ describe('getValueByKey 大小写容错', () => {
     expect(getValueByKey(row2, 'UUID')).toBe('x');
   });
 
+  it('多字符大小写差异(注入的扩展字段键保持原大小写)', () => {
+    // 后端注入的 IExtend.Items 键为 PascalCase(AvatarImage),行 JSON 其余为 camelCase
+    const injected = { id: 1, avatarImage: '/Sso/Avatar?id=1', AvatarImage: '/Sso/Avatar?id=1' };
+    expect(getValueByKey(injected, 'AvatarImage')).toBe('/Sso/Avatar?id=1');
+    expect(getValueByKey(injected, 'avatarImage')).toBe('/Sso/Avatar?id=1');
+    expect(getValueByKey(injected, 'AVATARIMAGE')).toBe('/Sso/Avatar?id=1');
+    // 大小写任意组合均可命中
+    expect(getValueByKey({ roleid: '1' }, 'RoleId')).toBe('1');
+    expect(getValueByKey({ DisplayName: '张三' }, 'displayname')).toBe('张三');
+  });
+
   it('不存在返回 undefined', () => {
     expect(getValueByKey(row, 'Nope')).toBeUndefined();
   });
 });
 
+describe('setValueByKey 大小写不敏感写入', () => {
+  it('不存在时新增', () => {
+    const data: Record<string, unknown> = {};
+    setValueByKey(data, 'name', 1);
+    expect(data).toEqual({ name: 1 });
+  });
+
+  it('已存在同键(忽略大小写)时覆盖原键', () => {
+    const data = { name: 1 };
+    setValueByKey(data, 'Name', 2);
+    expect(data).toEqual({ name: 2 });
+
+    const data2 = { Name: 1 };
+    setValueByKey(data2, 'name', 2);
+    expect(data2).toEqual({ Name: 2 });
+  });
+
+  it('精确键优先', () => {
+    const data = { Name: 1, name: 2 };
+    setValueByKey(data, 'Name', 3);
+    expect(data).toEqual({ Name: 3, name: 2 });
+  });
+});
+
 describe('resolveUrl 变量替换', () => {
   it('替换 {Id} 模板', () => {
     expect(resolveUrl('/Admin/User/Detail?id={Id}', { Id: 42 })).toBe('/Admin/User/Detail?id=42');
     expect(resolveUrl('/Admin/User/Detail?id={id}', { id: 7 })).toBe('/Admin/User/Detail?id=7');
   });
+
+  it('占位符与行键大小写不一致时仍可替换', () => {
+    // 行 JSON camelCase,模板占位符 PascalCase
+    expect(resolveUrl('/Admin/User/Detail?id={Id}', { id: 42 })).toBe('/Admin/User/Detail?id=42');
+    expect(resolveUrl('/Admin/UserConnect?userId={ID}', { id: 7 })).toBe('/Admin/UserConnect?userId=7');
+  });
 });
Modified +2 -2
diff --git a/NewLife.Cube.React/web/src/utils/fieldControl.ts b/NewLife.Cube.React/web/src/utils/fieldControl.ts
index be37de1..dbd0ad8 100644
--- a/NewLife.Cube.React/web/src/utils/fieldControl.ts
+++ b/NewLife.Cube.React/web/src/utils/fieldControl.ts
@@ -344,11 +344,11 @@ export function serializeSubmitModel(
   const multiNames = new Set(
     fields
       .filter((f) => f.multiple || (f.itemType ?? '').toLowerCase() === 'multipleselect')
-      .map((f) => f.name),
+      .map((f) => f.name.toLowerCase()),
   );
   const out: Record<string, unknown> = {};
   for (const [k, v] of Object.entries(model)) {
-    if (multiNames.has(k) && Array.isArray(v)) {
+    if (multiNames.has(k.toLowerCase()) && Array.isArray(v)) {
       out[k] = (v as unknown[]).map(String).join(',');
     } else {
       out[k] = v;
Modified +33 -12
diff --git a/NewLife.Cube.React/web/src/utils/url.ts b/NewLife.Cube.React/web/src/utils/url.ts
index 3ae42c0..c2b636d 100644
--- a/NewLife.Cube.React/web/src/utils/url.ts
+++ b/NewLife.Cube.React/web/src/utils/url.ts
@@ -38,25 +38,46 @@ export function routeToApiPrefix(path: string): string {
 }
 
 /**
- * 从数据对象中取值:先试 data[key],再翻转首字母(PascalCase ↔ camelCase),
- * 全大写 key(如 ID)转全小写再试,容错后端 JSON 字段名大小写不匹配。
+ * 从数据对象中取值,大小写不敏感。
+ *
+ * 后端实体 JSON 为 camelCase(name/displayName),字段元数据为 PascalCase(Name/DisplayName),
+ * 且注入的扩展字段(IExtend.Items)保持原键大小写(如 AvatarImage),
+ * 故统一按小写比对,避免 GetPage / 数据接口字段名大小写不一致导致取不到值。
+ * 后端约定同一表不会出现仅大小写不同的字段,此匹配安全。
  */
 export function getValueByKey(data: Record<string, unknown>, key: string): unknown {
   if (key in data) return data[key];
-  const flipped = toPascalAndCamel(key);
-  if (flipped !== key && flipped in data) return data[flipped];
-  if (key === key.toUpperCase() && key !== key.toLowerCase()) {
-    const lowerKey = key.toLowerCase();
-    if (lowerKey in data) return data[lowerKey];
-  }
-  if (key === key.toLowerCase() && /[a-z]/.test(key)) {
-    const upperKey = key.toUpperCase();
-    if (upperKey in data) return data[upperKey];
+  const lowerKey = key.toLowerCase();
+  // 行数据为普通 JSON 对象,for...in 仅遍历自身可枚举键
+  for (const k in data) {
+    if (k.toLowerCase() === lowerKey) return data[k];
   }
   return undefined;
 }
 
 /**
+ * 按大小写不敏感键名写入数据对象。已存在同键(忽略大小写)则覆盖原键,否则新增。
+ *
+ * @param data 目标对象
+ * @param key 键名(可任意大小写)
+ * @param value 值
+ */
+export function setValueByKey(data: Record<string, unknown>, key: string, value: unknown): void {
+  if (key in data) {
+    data[key] = value;
+    return;
+  }
+  const lowerKey = key.toLowerCase();
+  for (const k in data) {
+    if (k.toLowerCase() === lowerKey) {
+      data[k] = value;
+      return;
+    }
+  }
+  data[key] = value;
+}
+
+/**
  * URL 变量替换:将 `/path/{Id}` 替换为 `/path/123`
  *
  * @param url 含变量占位符的 URL 模板
@@ -69,4 +90,4 @@ export function resolveUrl(url: string, row: Record<string, unknown>): string {
   });
 }
 
-export default { toPascalCase, toCamelCase, routeToApiPrefix, getValueByKey, resolveUrl };
+export default { toPascalCase, toCamelCase, routeToApiPrefix, getValueByKey, setValueByKey, resolveUrl };
Modified +7 -4
diff --git a/NewLife.Cube.React/web/src/views/config/ConfigPage.tsx b/NewLife.Cube.React/web/src/views/config/ConfigPage.tsx
index 94951a8..a315f70 100644
--- a/NewLife.Cube.React/web/src/views/config/ConfigPage.tsx
+++ b/NewLife.Cube.React/web/src/views/config/ConfigPage.tsx
@@ -18,6 +18,7 @@ import type { DataField } from '@newlifex/api-core';
 import FieldControl from '@/components/field/FieldControl';
 import { groupByCategory, isFullWidthControl, resolveControl, serializeSubmitModel } from '@/utils/fieldControl';
 import { toFieldMeta, type FieldMeta } from '@/types/field';
+import { getValueByKey } from '@/utils/url';
 import { api } from '@/api';
 import { useAiFillForm } from '@/hooks/useAiFillForm';
 
@@ -67,11 +68,13 @@ export default function ConfigPage({ type }: ConfigPageProps) {
           .filter((f) => !f.primaryKey && !f.readOnly);
         setFields(metas);
 
-        // 回填当前值(布尔串转布尔)
+        // 回填当前值(布尔串转布尔;配置对象键与字段名大小写可能不一致,按字段名大小写不敏感取值)
         const values = (objRes?.data?.data ?? objRes?.data ?? {}) as Record<string, unknown>;
-        const normalized = Object.fromEntries(
-          Object.entries(values).map(([k, v]) => [k, normalizeValue(v)]),
-        );
+        const normalized: Record<string, unknown> = {};
+        for (const f of metas) {
+          const v = getValueByKey(values, f.name);
+          if (v !== undefined) normalized[f.name] = normalizeValue(v);
+        }
         form.setFieldsValue(normalized);
       })
       .finally(() => {
Modified +3 -3
diff --git a/NewLife.Cube.React/web/src/views/form/FormDialog.tsx b/NewLife.Cube.React/web/src/views/form/FormDialog.tsx
index 84fba9b..e910e68 100644
--- a/NewLife.Cube.React/web/src/views/form/FormDialog.tsx
+++ b/NewLife.Cube.React/web/src/views/form/FormDialog.tsx
@@ -9,7 +9,7 @@ import { App, Form, Modal, Row, Tabs } from 'antd';
 import FormFieldItem from './FormFieldItem';
 import { groupByCategory, hasCategory, mapDataToFormValues, serializeSubmitModel } from '@/utils/fieldControl';
 import { toFieldMeta, type FieldMeta } from '@/types/field';
-import { getValueByKey } from '@/utils/url';
+import { getValueByKey, setValueByKey } from '@/utils/url';
 import type { FieldMapping } from '@newlifex/field-mapping';
 import { useAiFillForm } from '@/hooks/useAiFillForm';
 
@@ -79,9 +79,9 @@ export default function FormDialog({
       // 从行数据按主键字段名显式合并,保证更新请求携带主键
       if (mode === 'edit' && row) {
         const pk = fields.find((f) => f.field.primaryKey)?.field.name;
-        if (pk && !(pk in model)) {
+        if (pk && getValueByKey(model, pk) === undefined) {
           const id = getValueByKey(row, pk);
-          if (id !== undefined) model[pk] = id;
+          if (id !== undefined) setValueByKey(model, pk, id);
         }
       }
       await onSubmit?.(model);
Modified +2 -2
diff --git a/NewLife.Cube.React/web/src/views/form/FormPage.tsx b/NewLife.Cube.React/web/src/views/form/FormPage.tsx
index 0de9d23..fb326cd 100644
--- a/NewLife.Cube.React/web/src/views/form/FormPage.tsx
+++ b/NewLife.Cube.React/web/src/views/form/FormPage.tsx
@@ -12,7 +12,7 @@ import { ArrowLeftOutlined } from '@ant-design/icons';
 import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
 import FormFieldItem from './FormFieldItem';
 import { groupByCategory, hasCategory, mapDataToFormValues, serializeSubmitModel } from '@/utils/fieldControl';
-import { routeToApiPrefix } from '@/utils/url';
+import { routeToApiPrefix, getValueByKey, setValueByKey } from '@/utils/url';
 import { toFieldMeta, type FieldMeta } from '@/types/field';
 import { usePageStore } from '@/hooks/usePageStore';
 import { useAiFillForm } from '@/hooks/useAiFillForm';
@@ -95,7 +95,7 @@ export default function FormPage({ title }: FormPageProps) {
       // 从路由 ?id= 显式合并,保证更新请求携带主键
       if (isEdit) {
         const pk = fields.find((f) => f.field.primaryKey)?.field.name;
-        if (pk && !(pk in model)) model[pk] = params.get('id') ?? '';
+        if (pk && getValueByKey(model, pk) === undefined) setValueByKey(model, pk, params.get('id') ?? '');
       }
       setSubmitting(true);
       if (isEdit) {
Modified +1 -1
diff --git a/NewLife.Cube.React/web/src/views/list/components/TableContent.tsx b/NewLife.Cube.React/web/src/views/list/components/TableContent.tsx
index 4bcbd5b..9ed5026 100644
--- a/NewLife.Cube.React/web/src/views/list/components/TableContent.tsx
+++ b/NewLife.Cube.React/web/src/views/list/components/TableContent.tsx
@@ -193,7 +193,7 @@ export default function TableContent({
   // 操作列:查看(只读/无编辑权限时)+ 编辑 + 删除(软删除行显示「恢复」)
   const isSoftDeleted = (row: Record<string, unknown>) => {
     if (!softDeleteField) return false;
-    const v = row[softDeleteField];
+    const v = getValueByKey(row, softDeleteField);
     return v === true || v === 'true' || v === 1;
   };