mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-11 15:32:24 +08:00
🐛 fix(frontend): 修复 Redis 搜索匹配与输入交互体验
- Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感 - Redis 标签页增加连接名与 host 摘要,区分同名 db 标签 - 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试 - 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题 - 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
70
frontend/src/utils/inputAutoCap.test.ts
Normal file
70
frontend/src/utils/inputAutoCap.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
26
frontend/src/utils/inputAutoCap.ts
Normal file
26
frontend/src/utils/inputAutoCap.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
|
||||
41
frontend/src/utils/redisSearchPattern.test.ts
Normal file
41
frontend/src/utils/redisSearchPattern.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
41
frontend/src/utils/redisSearchPattern.ts
Normal file
41
frontend/src/utils/redisSearchPattern.ts
Normal 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 === '',
|
||||
};
|
||||
};
|
||||
68
frontend/src/utils/tabDisplay.test.ts
Normal file
68
frontend/src/utils/tabDisplay.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
99
frontend/src/utils/tabDisplay.ts
Normal file
99
frontend/src/utils/tabDisplay.ts
Normal 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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user