feat(data-grid): 增加结果视图字段搜索定位 (#854)

## 关联 Issue

Closes #845

## 改动说明

- 在文本结果视图增加字段搜索,支持字段名和字段注释的前缀、包含匹配
- 文本视图选中唯一字段后自动滚动定位并高亮,不显示无意义的上下项导航
- 在 JSON 结果视图增加字段搜索,只定位每条记录的顶层真实字段键,避免命中字段值或嵌套 JSON 同名键
- JSON 视图支持上一项、下一项循环定位,并显示“当前 / 总数”
- 匹配顺序为字段完全匹配、字段前缀、字段包含、注释前缀、注释包含,忽略大小写并保留真实字段名
- 补充 Enter、Shift+Enter、Escape 键盘操作以及六种语言文案
- 统一搜索框与导航按钮为 24px 高度,移除叠加焦点光晕,并将输入及占位文字调整为 12px

## 验证

- `npm test -- --run src/utils/dataGridRecordFieldSearch.test.ts
src/components/DataGridRecordViews.interaction.test.tsx
src/components/DataGrid.layout.test.tsx`(46 tests passed)
- `npm run build`
- `go test ./shared/i18n`
This commit is contained in:
Syngnat
2026-08-06 12:11:54 +08:00
committed by GitHub
13 changed files with 845 additions and 41 deletions

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
collectDataGridRecordFieldCandidates,
findDataGridJsonFieldOccurrences,
resolveDataGridRecordFieldTarget,
} from './dataGridRecordFieldSearch';
describe('dataGridRecordFieldSearch', () => {
it('ranks exact, prefix, contains, and comment matches in a stable order', () => {
const candidates = collectDataGridRecordFieldCandidates({
fieldNames: ['legacy_title', 'title', 'title_suffix', 'display_name', 'summary'],
commentsByField: {
display_name: 'Title shown to users',
summary: 'Article title summary',
},
query: 'title',
includeComments: true,
});
expect(candidates.map(({ fieldName, matchKind }) => [fieldName, matchKind])).toEqual([
['title', 'field-exact'],
['title_suffix', 'field-prefix'],
['legacy_title', 'field-contains'],
['display_name', 'comment-prefix'],
['summary', 'comment-contains'],
]);
});
it('matches without case sensitivity and excludes comments when requested', () => {
expect(collectDataGridRecordFieldCandidates({
fieldNames: ['USER_ID', 'display_name'],
commentsByField: { display_name: 'User label' },
query: 'user',
}).map((candidate) => candidate.fieldName)).toEqual(['USER_ID']);
});
it('resolves only an exact or unique candidate automatically', () => {
const candidates = collectDataGridRecordFieldCandidates({
fieldNames: ['user_id', 'user_name'],
query: 'user',
});
expect(resolveDataGridRecordFieldTarget(candidates, 'user')).toBe('');
expect(resolveDataGridRecordFieldTarget(candidates, 'user_id')).toBe('user_id');
expect(resolveDataGridRecordFieldTarget(candidates.slice(0, 1), 'user')).toBe('user_id');
});
it('finds only top-level row properties in formatted JSON', () => {
const jsonText = JSON.stringify([
{ id: 1, profile: { id: 10 }, note: '"id": value text' },
{ id: 2, profile: { id: 20 } },
], null, 2);
const occurrences = findDataGridJsonFieldOccurrences(jsonText, 'id');
expect(occurrences).toHaveLength(2);
expect(occurrences.map((range) => jsonText.slice(range.start, range.end))).toEqual(['"id"', '"id"']);
});
it('handles field names that require JSON escaping', () => {
const fieldName = 'quoted"field';
const jsonText = JSON.stringify([{ [fieldName]: 1 }], null, 2);
const [occurrence] = findDataGridJsonFieldOccurrences(jsonText, fieldName);
expect(jsonText.slice(occurrence.start, occurrence.end)).toBe(JSON.stringify(fieldName));
});
});

View File

@@ -0,0 +1,104 @@
export interface DataGridRecordFieldCandidate {
fieldName: string;
comment: string;
sourceIndex: number;
matchKind: 'field-exact' | 'field-prefix' | 'field-contains' | 'comment-prefix' | 'comment-contains';
}
export interface DataGridJsonFieldOccurrence {
start: number;
end: number;
}
const normalizeSearchText = (value: unknown): string => String(value ?? '').trim().toLocaleLowerCase();
const resolveMatchKind = (
fieldName: string,
comment: string,
query: string,
includeComments: boolean,
): DataGridRecordFieldCandidate['matchKind'] | null => {
const normalizedFieldName = normalizeSearchText(fieldName);
if (normalizedFieldName === query) return 'field-exact';
if (normalizedFieldName.startsWith(query)) return 'field-prefix';
if (normalizedFieldName.includes(query)) return 'field-contains';
if (!includeComments) return null;
const normalizedComment = normalizeSearchText(comment);
if (normalizedComment.startsWith(query)) return 'comment-prefix';
if (normalizedComment.includes(query)) return 'comment-contains';
return null;
};
const MATCH_KIND_RANK: Record<DataGridRecordFieldCandidate['matchKind'], number> = {
'field-exact': 0,
'field-prefix': 1,
'field-contains': 2,
'comment-prefix': 3,
'comment-contains': 4,
};
export const collectDataGridRecordFieldCandidates = ({
fieldNames,
commentsByField = {},
query,
includeComments = false,
}: {
fieldNames: string[];
commentsByField?: Record<string, string>;
query: unknown;
includeComments?: boolean;
}): DataGridRecordFieldCandidate[] => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery) return [];
return fieldNames
.map((rawFieldName, sourceIndex) => {
const fieldName = String(rawFieldName || '').trim();
if (!fieldName) return null;
const comment = String(commentsByField[fieldName] || '').trim();
const matchKind = resolveMatchKind(fieldName, comment, normalizedQuery, includeComments);
return matchKind ? { fieldName, comment, sourceIndex, matchKind } : null;
})
.filter((candidate): candidate is DataGridRecordFieldCandidate => candidate !== null)
.sort((left, right) => (
MATCH_KIND_RANK[left.matchKind] - MATCH_KIND_RANK[right.matchKind]
|| left.sourceIndex - right.sourceIndex
));
};
export const resolveDataGridRecordFieldTarget = (
candidates: DataGridRecordFieldCandidate[],
query: unknown,
): string => {
const normalizedQuery = normalizeSearchText(query);
if (!normalizedQuery || candidates.length === 0) return '';
const exactMatch = candidates.find((candidate) => (
normalizeSearchText(candidate.fieldName) === normalizedQuery
));
if (exactMatch) return exactMatch.fieldName;
return candidates.length === 1 ? candidates[0].fieldName : '';
};
export const findDataGridJsonFieldOccurrences = (
jsonText: string,
fieldName: string,
): DataGridJsonFieldOccurrence[] => {
const normalizedFieldName = String(fieldName || '');
if (!jsonText || !normalizedFieldName) return [];
const serializedFieldName = JSON.stringify(normalizedFieldName);
const linePrefix = ` ${serializedFieldName}:`;
const occurrences: DataGridJsonFieldOccurrence[] = [];
let lineStart = 0;
String(jsonText).split('\n').forEach((line) => {
if (line.startsWith(linePrefix)) {
const start = lineStart + 4;
occurrences.push({ start, end: start + serializedFieldName.length });
}
lineStart += line.length + 1;
});
return occurrences;
};