字段名匹配全面大小写不敏感: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
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}
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) ?? ''),
})),
);
}
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 编辑回填映射', () => {
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');
+ });
});
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;
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 };
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(() => {
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);
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) {
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;
};