🐛 fix(frontend): 修复 Redis 搜索匹配与输入交互体验

- Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感
- Redis 标签页增加连接名与 host 摘要,区分同名 db 标签
- 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试
- 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题
- 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框
This commit is contained in:
Syngnat
2026-04-16 18:07:38 +08:00
parent d3a1c017da
commit af90936fcc
22 changed files with 541 additions and 122 deletions

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from './appearance';
import {
blurToFilter,
normalizeBlurForPlatform,
normalizeOpacityForPlatform,
resolveAppearanceValues,
resolveTextInputSafeBackdropFilter,
} from './appearance';
describe('appearance helpers', () => {
it('falls back to opaque non-blurred appearance when disabled', () => {
@@ -20,4 +26,10 @@ describe('appearance helpers', () => {
expect(blurToFilter(0)).toBeUndefined();
expect(blurToFilter(8)).toBe('blur(8px)');
});
it('disables local backdrop blur for text-entry surfaces on macOS', () => {
expect(resolveTextInputSafeBackdropFilter('blur(18px)', true)).toBe('none');
expect(resolveTextInputSafeBackdropFilter('blur(18px)', false)).toBe('blur(18px)');
expect(resolveTextInputSafeBackdropFilter(undefined, true)).toBe('none');
});
});

View File

@@ -80,3 +80,16 @@ export const normalizeBlurForPlatform = (blur: number | undefined): number => {
export const blurToFilter = (blur: number): string | undefined => {
return blur > 0 ? `blur(${blur}px)` : undefined;
};
// macOS WebView 下,文本输入区域祖先节点的 backdrop-filter 会和输入法候选/切换浮层叠加,
// 造成额外的透明框。这里允许交互面板按平台降级为非模糊背景。
export const resolveTextInputSafeBackdropFilter = (
backdropFilter: string | undefined,
disableForMacLike: boolean = isMacLikePlatform(),
): string => {
const normalized = String(backdropFilter || '').trim();
if (!normalized || normalized === 'none') {
return 'none';
}
return disableForMacLike ? 'none' : normalized;
};

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { applyNoAutoCapAttributes, applyNoAutoCapAttributesWithin, noAutoCapInputProps } from './inputAutoCap';
describe('inputAutoCap', () => {
it('exports input props that disable auto capitalization and correction', () => {
expect(noAutoCapInputProps).toEqual({
autoCapitalize: 'none',
autoCorrect: 'off',
spellCheck: false,
});
});
it('applies lowercase DOM attributes to inputs and textareas', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
tagName: 'INPUT',
setAttribute: (key: string, value: string) => {
inputAttributes[key] = value;
},
} as unknown as Element;
const textarea = {
tagName: 'TEXTAREA',
setAttribute: (key: string, value: string) => {
textareaAttributes[key] = value;
},
} as unknown as Element;
applyNoAutoCapAttributes(input);
applyNoAutoCapAttributes(textarea);
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(inputAttributes.spellcheck).toBe('false');
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
expect(textareaAttributes.spellcheck).toBe('false');
});
it('applies no-auto-cap attributes to all nested inputs and textareas within a container', () => {
const inputAttributes: Record<string, string> = {};
const textareaAttributes: Record<string, string> = {};
const input = {
tagName: 'INPUT',
setAttribute: (key: string, value: string) => {
inputAttributes[key] = value;
},
} as unknown as Element;
const textarea = {
tagName: 'TEXTAREA',
setAttribute: (key: string, value: string) => {
textareaAttributes[key] = value;
},
} as unknown as Element;
const root = {
querySelectorAll: (selector: string) => {
expect(selector).toBe('input, textarea');
return [input, textarea];
},
} as unknown as ParentNode;
applyNoAutoCapAttributesWithin(root);
expect(inputAttributes.autocapitalize).toBe('none');
expect(inputAttributes.autocorrect).toBe('off');
expect(textareaAttributes.autocapitalize).toBe('none');
expect(textareaAttributes.autocorrect).toBe('off');
});
});

View File

@@ -0,0 +1,26 @@
export const noAutoCapInputProps = {
autoCapitalize: 'none' as const,
autoCorrect: 'off' as const,
spellCheck: false,
};
export const applyNoAutoCapAttributes = (element: Element) => {
const tagName = String((element as Element | null)?.tagName || '').toUpperCase();
if (tagName !== 'INPUT' && tagName !== 'TEXTAREA') {
return;
}
element.setAttribute('autocapitalize', 'none');
element.setAttribute('autocorrect', 'off');
element.setAttribute('spellcheck', 'false');
};
export const applyNoAutoCapAttributesWithin = (root: ParentNode | null | undefined) => {
if (!root || typeof root.querySelectorAll !== 'function') {
return;
}
root.querySelectorAll('input, textarea').forEach((element) => {
applyNoAutoCapAttributes(element);
});
};

View File

@@ -18,4 +18,9 @@ describe('buildOverlayWorkbenchTheme', () => {
expect(lightTheme.sectionBg).toMatch(/rgba\(255,?\s*255,?\s*255,?\s*0\.84\)/);
expect(lightTheme.iconColor).toBe('#1677ff');
});
it('can disable shell blur for macOS text-entry compatibility', () => {
const darkTheme = buildOverlayWorkbenchTheme(true, { disableBackdropFilter: true });
expect(darkTheme.shellBackdropFilter).toBe('none');
});
});

View File

@@ -1,3 +1,5 @@
import { resolveTextInputSafeBackdropFilter } from './appearance';
type OverlayWorkbenchTheme = {
isDark: boolean;
shellBg: string;
@@ -16,14 +18,22 @@ type OverlayWorkbenchTheme = {
divider: string;
};
export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchTheme => {
export const buildOverlayWorkbenchTheme = (
darkMode: boolean,
options?: { disableBackdropFilter?: boolean },
): OverlayWorkbenchTheme => {
const shellBackdropFilter = resolveTextInputSafeBackdropFilter(
darkMode ? 'blur(18px)' : 'none',
options?.disableBackdropFilter ?? false,
);
if (darkMode) {
return {
isDark: true,
shellBg: 'linear-gradient(180deg, rgba(15, 15, 17, 0.96) 0%, rgba(11, 11, 13, 0.98) 100%)',
shellBorder: '1px solid rgba(255,255,255,0.08)',
shellShadow: '0 24px 56px rgba(0,0,0,0.34)',
shellBackdropFilter: 'blur(18px)',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.03)',
sectionBorder: '1px solid rgba(255,255,255,0.08)',
mutedText: 'rgba(255,255,255,0.5)',
@@ -42,7 +52,7 @@ export const buildOverlayWorkbenchTheme = (darkMode: boolean): OverlayWorkbenchT
shellBg: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)',
shellBorder: '1px solid rgba(16,24,40,0.08)',
shellShadow: '0 18px 42px rgba(15,23,42,0.12)',
shellBackdropFilter: 'none',
shellBackdropFilter,
sectionBg: 'rgba(255,255,255,0.84)',
sectionBorder: '1px solid rgba(16,24,40,0.08)',
mutedText: 'rgba(16,24,40,0.55)',

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from './redisSearchPattern';
describe('normalizeRedisSearchInput', () => {
it('returns wildcard for empty input', () => {
expect(normalizeRedisSearchInput('')).toEqual({
keyword: '',
pattern: '*',
});
});
it('wraps plain keywords with wildcard for contains matching', () => {
expect(normalizeRedisSearchInput('order')).toEqual({
keyword: 'order',
pattern: '*[oO][rR][dD][eE][rR]*',
});
});
it('builds ascii case-insensitive patterns for letter keywords', () => {
expect(normalizeRedisSearchInput('agent')).toEqual({
keyword: 'agent',
pattern: '*[aA][gG][eE][nN][tT]*',
});
});
it('escapes redis glob special characters as literals', () => {
expect(normalizeRedisSearchInput('user:*:[id]?')).toEqual({
keyword: 'user:*:[id]?',
pattern: '*[uU][sS][eE][rR]:\\*:\\[[iI][dD]\\]\\?*',
});
});
it('marks empty draft changes for immediate reset search', () => {
expect(normalizeRedisSearchDraftChange('')).toEqual({
keyword: '',
pattern: '*',
shouldSearchImmediately: true,
});
});
});

View File

@@ -0,0 +1,41 @@
const REDIS_GLOB_SPECIAL_CHARS = /([*?\[\]\\])/g;
const ASCII_LETTER = /^[A-Za-z]$/;
const escapeRedisGlobLiteral = (value: string): string => {
return value.replace(REDIS_GLOB_SPECIAL_CHARS, '\\$1');
};
const toCaseInsensitiveRedisGlobLiteral = (value: string): string => {
return Array.from(value).map((char) => {
if (!ASCII_LETTER.test(char)) {
return escapeRedisGlobLiteral(char);
}
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return `[${lower}${upper}]`;
}).join('');
};
export const normalizeRedisSearchInput = (rawValue: string): { keyword: string; pattern: string } => {
const keyword = String(rawValue || '').trim();
if (!keyword) {
return { keyword: '', pattern: '*' };
}
return {
keyword,
pattern: `*${toCaseInsensitiveRedisGlobLiteral(keyword)}*`,
};
};
export const normalizeRedisSearchDraftChange = (rawValue: string): {
keyword: string;
pattern: string;
shouldSearchImmediately: boolean;
} => {
const normalized = normalizeRedisSearchInput(rawValue);
return {
...normalized,
shouldSearchImmediately: normalized.keyword === '',
};
};

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection, TabData } from '../types';
import { buildTabDisplayTitle, resolveConnectionHostSummary } from './tabDisplay';
const redisConnection: SavedConnection = {
id: 'redis-1',
name: '订单缓存',
config: {
type: 'redis',
host: '10.10.0.12',
port: 6379,
user: '',
database: '',
hosts: ['10.10.0.13:6379', '10.10.0.14:6379'],
},
};
describe('tabDisplay', () => {
it('builds compact host summary for multi-host redis connections', () => {
expect(resolveConnectionHostSummary(redisConnection.config)).toBe('10.10.0.12 +2');
});
it('adds connection and host identity to redis key tabs', () => {
const redisKeysTab: TabData = {
id: 'redis-keys-redis-1-db0',
title: 'db0',
type: 'redis-keys',
connectionId: 'redis-1',
redisDB: 0,
};
expect(buildTabDisplayTitle(redisKeysTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] db0');
});
it('normalizes redis command and monitor tabs to db-scoped labels', () => {
const commandTab: TabData = {
id: 'cmd-1',
title: '命令 - db1',
type: 'redis-command',
connectionId: 'redis-1',
redisDB: 1,
};
const monitorTab: TabData = {
id: 'monitor-1',
title: '监控: 订单缓存',
type: 'redis-monitor',
connectionId: 'redis-1',
redisDB: 1,
};
expect(buildTabDisplayTitle(commandTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 命令 - db1');
expect(buildTabDisplayTitle(monitorTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 监控 - db1');
});
it('keeps table tabs on the existing prefix strategy', () => {
const tableTab: TabData = {
id: 'table-1',
title: 'orders',
type: 'table',
connectionId: 'redis-1',
dbName: 'app',
tableName: 'orders',
};
expect(buildTabDisplayTitle(tableTab, redisConnection)).toBe('[订单缓存] orders');
});
});

View File

@@ -0,0 +1,99 @@
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
export const detectConnectionEnvLabel = (connectionName: string): string | null => {
const tokens = connectionName.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
if (tokens.includes('prod') || tokens.includes('production')) return 'PROD';
if (tokens.includes('uat')) return 'UAT';
if (tokens.includes('dev') || tokens.includes('development')) return 'DEV';
if (tokens.includes('sit')) return 'SIT';
if (tokens.includes('stg') || tokens.includes('stage') || tokens.includes('staging') || tokens.includes('pre')) return 'STG';
if (tokens.includes('test') || tokens.includes('qa')) return 'TEST';
return null;
};
const parseHostOnlyToken = (value: unknown): string[] => {
const raw = String(value || '').trim();
if (!raw) {
return [];
}
let text = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
if (text.includes('/')) {
text = text.split('/')[0];
}
if (text.includes('?')) {
text = text.split('?')[0];
}
if (text.includes('@')) {
text = text.split('@').pop() || '';
}
return text
.split(',')
.map((entry) => {
const token = entry.trim();
if (!token) return '';
if (token.startsWith('[')) {
const rightBracketIndex = token.indexOf(']');
if (rightBracketIndex > 0) {
return token.slice(0, rightBracketIndex + 1).toLowerCase();
}
}
const colonIndex = token.lastIndexOf(':');
if (colonIndex > 0) {
return token.slice(0, colonIndex).toLowerCase();
}
return token.toLowerCase();
})
.filter(Boolean);
};
export const resolveConnectionHostTokens = (config?: ConnectionConfig): string[] => {
if (!config) {
return [];
}
return Array.from(new Set([
...parseHostOnlyToken(config.host),
...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry) => parseHostOnlyToken(entry)) : []),
...parseHostOnlyToken(config.uri),
]));
};
export const resolveConnectionHostSummary = (config?: ConnectionConfig): string => {
const hosts = resolveConnectionHostTokens(config);
if (hosts.length === 0) return '';
if (hosts.length === 1) return hosts[0];
return `${hosts[0]} +${hosts.length - 1}`;
};
const isRedisTab = (tab: TabData): boolean => {
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
};
const buildRedisBaseTitle = (tab: TabData): string => {
const dbLabel = `db${tab.redisDB ?? 0}`;
if (tab.type === 'redis-command') return `命令 - ${dbLabel}`;
if (tab.type === 'redis-monitor') return `监控 - ${dbLabel}`;
return dbLabel;
};
export const buildTabDisplayTitle = (tab: TabData, connection?: SavedConnection): string => {
const connectionName = String(connection?.name || '').trim();
if (isRedisTab(tab)) {
const hostSummary = resolveConnectionHostSummary(connection?.config);
const identity = [connectionName, hostSummary].filter(Boolean).join(' | ');
return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab);
}
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
return tab.title;
}
if (!connectionName) {
return tab.title;
}
const prefix = detectConnectionEnvLabel(connectionName) || connectionName;
return `[${prefix}] ${tab.title}`;
};