From af90936fccf553a077ef83f25f33ab906b45a924 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Thu, 16 Apr 2026 18:07:38 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(frontend):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Redis=20=E6=90=9C=E7=B4=A2=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E4=B8=8E=E8=BE=93=E5=85=A5=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感 - Redis 标签页增加连接名与 host 摘要,区分同名 db 标签 - 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试 - 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题 - 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框 --- frontend/src/App.tsx | 8 +- frontend/src/components/ConnectionModal.tsx | 26 ++--- frontend/src/components/DataGrid.tsx | 35 ++++++- frontend/src/components/DataSyncModal.tsx | 7 +- .../src/components/FindInDatabaseModal.tsx | 6 +- frontend/src/components/RedisViewer.tsx | 67 ++++++++++--- frontend/src/components/Sidebar.tsx | 68 ++++--------- frontend/src/components/TabManager.tsx | 25 +---- frontend/src/components/TableDesigner.tsx | 8 +- frontend/src/components/TableOverview.tsx | 3 + .../redisViewerWorkbenchTheme.test.ts | 5 + .../components/redisViewerWorkbenchTheme.ts | 12 ++- frontend/src/utils/appearance.test.ts | 14 ++- frontend/src/utils/appearance.ts | 13 +++ frontend/src/utils/inputAutoCap.test.ts | 70 +++++++++++++ frontend/src/utils/inputAutoCap.ts | 26 +++++ .../src/utils/overlayWorkbenchTheme.test.ts | 5 + frontend/src/utils/overlayWorkbenchTheme.ts | 16 ++- frontend/src/utils/redisSearchPattern.test.ts | 41 ++++++++ frontend/src/utils/redisSearchPattern.ts | 41 ++++++++ frontend/src/utils/tabDisplay.test.ts | 68 +++++++++++++ frontend/src/utils/tabDisplay.ts | 99 +++++++++++++++++++ 22 files changed, 541 insertions(+), 122 deletions(-) create mode 100644 frontend/src/utils/inputAutoCap.test.ts create mode 100644 frontend/src/utils/inputAutoCap.ts create mode 100644 frontend/src/utils/redisSearchPattern.test.ts create mode 100644 frontend/src/utils/redisSearchPattern.ts create mode 100644 frontend/src/utils/tabDisplay.test.ts create mode 100644 frontend/src/utils/tabDisplay.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6783724..c6f5b3f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,7 +18,7 @@ import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModa import SecurityUpdateSettingsModal from './components/SecurityUpdateSettingsModal'; import { DEFAULT_APPEARANCE, useStore } from './store'; import { SavedConnection, SecurityUpdateIssue, SecurityUpdateStatus } from './types'; -import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; +import { blurToFilter, isMacLikePlatform, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; import { DATA_GRID_COLUMN_WIDTH_MODE_OPTIONS, sanitizeDataTableColumnWidthMode } from './utils/dataGridDisplay'; import { getMacNativeTitlebarPaddingLeft, getMacNativeTitlebarPaddingRight, shouldHandleMacNativeFullscreenShortcut, shouldSuppressMacNativeEscapeExit } from './utils/macWindow'; import { shouldEnableMacWindowDiagnostics } from './utils/macWindowDiagnostics'; @@ -812,7 +812,11 @@ function App() { whiteSpace: 'nowrap', fontSize: isSidebarCompact ? 13 : 14, }), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, isSidebarCompact, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]); - const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); + const disableLocalBackdropFilter = isMacLikePlatform(); + const overlayTheme = useMemo( + () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }), + [darkMode, disableLocalBackdropFilter], + ); const sidebarQuickActionBaseStyle = useMemo(() => ({ height: Math.max(34, Math.round(36 * effectiveUiScale)), diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 6cf04f4..8ee5a32 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -4,7 +4,7 @@ import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutl import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; -import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getStoredSecretPlaceholder, normalizeConnectionSecretErrorMessage, @@ -12,6 +12,7 @@ import { } from '../utils/connectionModalPresentation'; import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft'; import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn'; +import { applyNoAutoCapAttributes, noAutoCapInputProps } from '../utils/inputAutoCap'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; @@ -24,21 +25,6 @@ const CONNECTION_MODAL_WIDTH = 960; const CONNECTION_MODAL_BODY_HEIGHT = 620; const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)'; const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)'; -const noAutoCapInputProps = { - autoCapitalize: 'none' as const, - autoCorrect: 'off' as const, - spellCheck: false, -}; - -const applyNoAutoCapAttributes = (element: Element) => { - if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) { - return; - } - element.setAttribute('autocapitalize', 'none'); - element.setAttribute('autocorrect', 'off'); - element.setAttribute('spellcheck', 'false'); -}; - type ConnectionSecretKey = | 'primaryPassword' | 'sshPassword' @@ -177,6 +163,7 @@ const ConnectionModal: React.FC<{ const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const disableLocalBackdropFilter = isMacLikePlatform(); const mysqlTopology = Form.useWatch('mysqlTopology', form) || 'single'; const mongoTopology = Form.useWatch('mongoTopology', form) || 'single'; const mongoSrv = Form.useWatch('mongoSrv', form) || false; @@ -207,7 +194,10 @@ const ConnectionModal: React.FC<{ const step1SidebarDividerColor = darkMode ? STEP1_SIDEBAR_DIVIDER_DARK : STEP1_SIDEBAR_DIVIDER_LIGHT; const step1SidebarActiveBg = darkMode ? 'rgba(246, 196, 83, 0.20)' : '#e6f4ff'; const step1SidebarActiveColor = darkMode ? '#ffd666' : '#1677ff'; - const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); + const overlayTheme = useMemo( + () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }), + [darkMode, disableLocalBackdropFilter], + ); const tunnelSectionStyle: React.CSSProperties = { padding: '12px', @@ -3233,5 +3223,3 @@ const ConnectionModal: React.FC<{ export default ConnectionModal; - - diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 896dc76..81a0213 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -50,6 +50,7 @@ import { } from './dataGridCopyInsert'; import { calculateAutoFitColumnWidth } from './dataGridAutoWidth'; import { buildSelectedCellClipboardText } from './dataGridSelectionCopy'; +import { applyNoAutoCapAttributesWithin, noAutoCapInputProps } from '../utils/inputAutoCap'; // --- Error Boundary --- interface DataGridErrorBoundaryState { @@ -2234,6 +2235,7 @@ const DataGrid: React.FC = ({ // Filter State const [filterConditions, setFilterConditions] = useState([]); const [nextFilterId, setNextFilterId] = useState(1); + const filterPanelRef = useRef(null); useEffect(() => { const nextConditions = normalizeGridFilterConditions(appliedFilterConditions); @@ -2242,6 +2244,30 @@ const DataGrid: React.FC = ({ setNextFilterId(Math.max(1, maxId + 1)); }, [appliedFilterConditions, normalizeGridFilterConditions]); + useEffect(() => { + if (!showFilter) { + return; + } + const root = filterPanelRef.current; + if (!root) { + return; + } + const apply = () => { + applyNoAutoCapAttributesWithin(root); + }; + apply(); + if (typeof MutationObserver === 'undefined') { + return; + } + const observer = new MutationObserver(() => { + apply(); + }); + observer.observe(root, { childList: true, subtree: true }); + return () => { + observer.disconnect(); + }; + }, [showFilter]); + const selectedRowKeysRef = useRef(selectedRowKeys); const displayDataRef = useRef([]); @@ -5135,7 +5161,7 @@ const DataGrid: React.FC = ({ {showFilter && ( -
= ({ {cond.op === 'CUSTOM' ? ( = ({ /> ) : isListOp(cond.op) ? ( = ({ ) : isBetweenOp(cond.op) ? ( <> updateFilter(cond.id, 'value', e.target.value)} placeholder="开始值" /> updateFilter(cond.id, 'value2', e.target.value)} @@ -5214,9 +5244,10 @@ const DataGrid: React.FC = ({ /> ) : isNoValueOp(cond.op) ? ( - + ) : ( updateFilter(cond.id, 'value', e.target.value)} diff --git a/frontend/src/components/DataSyncModal.tsx b/frontend/src/components/DataSyncModal.tsx index 720eb3e..f771915 100644 --- a/frontend/src/components/DataSyncModal.tsx +++ b/frontend/src/components/DataSyncModal.tsx @@ -5,7 +5,7 @@ import { useStore } from '../store'; import { DBGetDatabases, DBGetTables, DataSync, DataSyncAnalyze, DataSyncPreview } from '../../wailsjs/go/app/App'; import { SavedConnection } from '../types'; import { EventsOn } from '../../wailsjs/runtime/runtime'; -import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues, resolveTextInputSafeBackdropFilter } from '../utils/appearance'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { formatLocalDateTimeLiteral, normalizeTemporalLiteralText } from './dataGridCopyInsert'; @@ -190,6 +190,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, const darkMode = themeMode === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const disableLocalBackdropFilter = isMacLikePlatform(); // Step 1: Config const [sourceConnId, setSourceConnId] = useState(''); @@ -630,8 +631,8 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.36)' : '0 18px 44px rgba(15,23,42,0.14)', - backdropFilter: darkMode ? 'blur(18px)' : 'none', - }), [darkMode]); + backdropFilter: resolveTextInputSafeBackdropFilter(darkMode ? 'blur(18px)' : 'none', disableLocalBackdropFilter), + }), [darkMode, disableLocalBackdropFilter]); const shellCardStyle = useMemo(() => ({ borderRadius: 18, diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx index 2a29484..a81ea41 100644 --- a/frontend/src/components/FindInDatabaseModal.tsx +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -6,6 +6,7 @@ import { quoteIdentPart, escapeLiteral } from '../utils/sql'; import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { isMacLikePlatform } from '../utils/appearance'; interface FindInDatabaseModalProps { open: boolean; @@ -67,14 +68,15 @@ const FindInDatabaseModal: React.FC = ({ open, onClose const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); + const disableLocalBackdropFilter = isMacLikePlatform(); const conn = useMemo(() => connections.find(c => c.id === connectionId), [connections, connectionId]); const dbType = useMemo(() => (conn?.config?.type || 'mysql').toLowerCase(), [conn]); const wt = useMemo(() => { const isDark = theme === 'dark'; - return buildOverlayWorkbenchTheme(isDark); - }, [theme]); + return buildOverlayWorkbenchTheme(isDark, { disableBackdropFilter: disableLocalBackdropFilter }); + }, [disableLocalBackdropFilter, theme]); const buildConfig = useCallback(() => { if (!conn) return null; diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 6a1a4ff..fbbf4db 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -6,7 +6,14 @@ import { useStore } from '../store'; import { RedisKeyInfo, RedisValue, StreamEntry } from '../types'; import Editor from '@monaco-editor/react'; import type { DataNode } from 'antd/es/tree'; -import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; +import { + blurToFilter, + isMacLikePlatform, + normalizeBlurForPlatform, + normalizeOpacityForPlatform, + resolveAppearanceValues, + resolveTextInputSafeBackdropFilter, +} from '../utils/appearance'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { applyRenamedRedisKeyState, @@ -19,6 +26,8 @@ import { type RedisTreeDataNode, } from './redisViewerTree'; import { buildRedisWorkbenchTheme } from './redisViewerWorkbenchTheme'; +import { noAutoCapInputProps } from '../utils/inputAutoCap'; +import { normalizeRedisSearchDraftChange, normalizeRedisSearchInput } from '../utils/redisSearchPattern'; const { Search } = Input; @@ -283,8 +292,16 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const blur = normalizeBlurForPlatform(resolvedAppearance.blur); + const disableLocalBackdropFilter = isMacLikePlatform(); const connection = connections.find(c => c.id === connectionId); - const workbenchTheme = useMemo(() => buildRedisWorkbenchTheme({ darkMode, opacity, blur }), [blur, darkMode, opacity]); + const workbenchTheme = useMemo( + () => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }), + [blur, darkMode, disableLocalBackdropFilter, opacity], + ); + const workbenchBackdropFilter = useMemo( + () => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter), + [blur, disableLocalBackdropFilter], + ); const keyAccentColor = workbenchTheme.accent; const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff'; const valueToolbarBg = workbenchTheme.panelBgStrong; @@ -293,6 +310,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); + const [searchInput, setSearchInput] = useState(''); const [searchPattern, setSearchPattern] = useState('*'); const [cursor, setCursor] = useState('0'); const [hasMore, setHasMore] = useState(false); @@ -467,13 +485,29 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { useEffect(() => { loadKeys(searchPattern, '0', false, getRedisScanLoadCount(searchPattern, false)); - }, [redisDB]); + }, [loadKeys, redisDB]); + + const executeSearch = useCallback((value: string) => { + const normalized = normalizeRedisSearchInput(value); + setSearchInput(normalized.keyword); + setSearchPattern(normalized.pattern); + setCursor('0'); + loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false)); + }, [loadKeys]); const handleSearch = (value: string) => { - const pattern = value.trim() || '*'; - setSearchPattern(pattern); + executeSearch(value); + }; + + const handleSearchInputChange = (event: React.ChangeEvent) => { + const normalized = normalizeRedisSearchDraftChange(event.target.value); + setSearchInput(normalized.keyword); + if (!normalized.shouldSearchImmediately) { + return; + } + setSearchPattern(normalized.pattern); setCursor('0'); - loadKeys(pattern, '0', false, getRedisScanLoadCount(pattern, false)); + loadKeys(normalized.pattern, '0', false, getRedisScanLoadCount(normalized.pattern, false)); }; const handleLoadMore = () => { @@ -1214,9 +1248,9 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { title: '添加字段', content: (
- - - + + + @@ -1888,7 +1922,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
- +
@@ -2050,7 +2084,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { } return ( -
+
{/* Left: Key List */}
@@ -2063,9 +2097,12 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
} /> @@ -2177,7 +2214,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { > - + @@ -2207,7 +2244,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { rules={[{ required: true, message: '请输入新的 Key 名称' }]} extra={renameTargetKey ? `原始 Key:${renameTargetKey}` : undefined} > - + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 67f9d85..8888e0b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -41,11 +41,13 @@ import { getDbIcon } from './DatabaseIcons'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, ExecuteSQLFile, CancelSQLFileExecution, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { EventsOn } from '../../wailsjs/runtime/runtime'; - import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; + import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import FindInDatabaseModal from './FindInDatabaseModal'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata'; +import { resolveConnectionHostTokens } from '../utils/tabDisplay'; const { Search } = Input; @@ -138,6 +140,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const darkMode = theme === 'dark'; const resolvedAppearance = resolveAppearanceValues(appearance); const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const disableLocalBackdropFilter = isMacLikePlatform(); const autoFetchVisible = useAutoFetchVisibility(); const [treeData, setTreeData] = useState([]); @@ -151,7 +154,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return `rgba(${r}, ${g}, ${b}, ${opacity})`; }; const bgMain = getBg('#141414'); - const overlayTheme = useMemo(() => buildOverlayWorkbenchTheme(darkMode), [darkMode]); + const overlayTheme = useMemo( + () => buildOverlayWorkbenchTheme(darkMode, { disableBackdropFilter: disableLocalBackdropFilter }), + [darkMode, disableLocalBackdropFilter], + ); const modalPanelStyle = useMemo(() => ({ background: overlayTheme.shellBg, border: overlayTheme.shellBorder, @@ -2858,51 +2864,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> ); }, [darkMode, overlayTheme, searchScopes]); - 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); - }; - const getConnectionHostSearchText = (node: TreeNode): string => { if (node.type !== 'connection') return ''; const config = node.dataRef?.config || {}; - const hostTokens = [ - ...parseHostOnlyToken(config.host), - ...(Array.isArray(config.hosts) ? config.hosts.flatMap((entry: string) => parseHostOnlyToken(entry)) : []), - ...parseHostOnlyToken(config.uri), - ]; - const uniqueHosts = Array.from(new Set(hostTokens)); - return uniqueHosts.join(' '); + return resolveConnectionHostTokens(config).join(' '); }; const getConnectionNameSearchText = (node: TreeNode): string => { @@ -3110,7 +3075,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> onClick: () => { addTab({ id: `redis-cmd-${node.key}-${Date.now()}`, - title: `命令 - ${node.title}`, + title: '命令 - db0', type: 'redis-command', connectionId: node.key, redisDB: 0 @@ -3124,7 +3089,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> onClick: () => { addTab({ id: `redis-monitor-${node.key}-${Date.now()}`, - title: `监控: ${node.title}`, + title: '监控 - db0', type: 'redis-monitor', connectionId: node.key, redisDB: 0 @@ -3386,7 +3351,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> onClick: () => { addTab({ id: `redis-monitor-${id}-db${redisDB}-${Date.now()}`, - title: `监控: ${connections.find(c => c.id === id)?.name || id}`, + title: `监控 - db${redisDB}`, type: 'redis-monitor', connectionId: id, redisDB: redisDB @@ -3835,6 +3800,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
void }> >
- + {/* Charset option could be added here */}
@@ -4044,7 +4010,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> >
- +
@@ -4061,7 +4027,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> >
- +
@@ -4078,7 +4044,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> >
- +
diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index acf5439..89b08b6 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -17,24 +17,7 @@ import TriggerViewer from './TriggerViewer'; import DefinitionViewer from './DefinitionViewer'; import TableOverview from './TableOverview'; import type { TabData } from '../types'; - -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 buildTabDisplayTitle = (tab: TabData, connectionName: string | undefined): string => { - 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}`; -}; +import { buildTabDisplayTitle } from '../utils/tabDisplay'; type SortableTabLabelProps = { displayTitle: string; @@ -50,7 +33,7 @@ const SortableTabLabel: React.FC = ({ e.preventDefault()} - title="拖拽调整标签顺序" + title={displayTitle} > {displayTitle} @@ -198,8 +181,8 @@ const TabManager: React.FC = () => { ); const items = useMemo(() => tabs.map((tab, index) => { - const connectionName = connections.find((conn) => conn.id === tab.connectionId)?.name; - const displayTitle = buildTabDisplayTitle(tab, connectionName); + const connection = connections.find((conn) => conn.id === tab.connectionId); + const displayTitle = buildTabDisplayTitle(tab, connection); const tabIsActive = tab.id === activeTabId; let content; if (tab.type === 'query') { diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 1019eb1..1589107 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -11,6 +11,7 @@ import { DBGetColumns, DBGetIndexes, DBQuery, DBGetForeignKeys, DBGetTriggers, D import { hasIndexFormChanged, normalizeIndexFormFromRow, shouldRestoreOriginalIndex, toggleIndexSelection as getNextIndexSelection, type IndexDisplaySnapshot } from './tableDesignerIndexUtils'; import { buildAlterTablePreviewSql } from './tableDesignerSchemaSql'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { noAutoCapInputProps } from '../utils/inputAutoCap'; interface EditableColumn extends ColumnDefinition { _key: string; @@ -546,7 +547,7 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => { key: 'name', width: 180, render: (text: string, record: EditableColumn) => readOnly ? text : ( - handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" /> + handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" /> ) }, { @@ -2492,6 +2493,7 @@ END;`; {isNewTable && ( <> setNewTableName(e.target.value)} @@ -2805,6 +2807,7 @@ END;`; 已选择字段:{selectedColumns.length}
setCopyTableName(e.target.value)} @@ -2865,6 +2868,7 @@ END;`; > setIndexForm(prev => ({ ...prev, name: e.target.value }))} @@ -2934,6 +2938,7 @@ END;`; > setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))} @@ -2949,6 +2954,7 @@ END;`; style={{ width: '100%' }} /> setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))} diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 5672be6..448f21f 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -6,6 +6,7 @@ import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from import type { TabData } from '../types'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { noAutoCapInputProps } from '../utils/inputAutoCap'; import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; interface TableOverviewProps { @@ -344,6 +345,7 @@ const TableOverview: React.FC = ({ tab }) => { title: '重命名表', content: ( { newName = e.target.value; }} placeholder="输入新表名" @@ -417,6 +419,7 @@ const TableOverview: React.FC = ({ tab }) => {
} value={searchText} diff --git a/frontend/src/components/redisViewerWorkbenchTheme.test.ts b/frontend/src/components/redisViewerWorkbenchTheme.test.ts index 8a64063..72cd0b8 100644 --- a/frontend/src/components/redisViewerWorkbenchTheme.test.ts +++ b/frontend/src/components/redisViewerWorkbenchTheme.test.ts @@ -25,4 +25,9 @@ describe('buildRedisWorkbenchTheme', () => { expect(lightTheme.statusTagBg).not.toBe(lightTheme.statusTagMutedBg); expect(lightTheme.backdropFilter).toBe('none'); }); + + it('can disable redis workbench blur for macOS text-entry compatibility', () => { + const darkTheme = buildRedisWorkbenchTheme({ darkMode: true, opacity: 0.72, blur: 14, disableBackdropFilter: true }); + expect(darkTheme.backdropFilter).toBe('none'); + }); }); diff --git a/frontend/src/components/redisViewerWorkbenchTheme.ts b/frontend/src/components/redisViewerWorkbenchTheme.ts index 9c24cf0..3b57629 100644 --- a/frontend/src/components/redisViewerWorkbenchTheme.ts +++ b/frontend/src/components/redisViewerWorkbenchTheme.ts @@ -1,7 +1,10 @@ +import { resolveTextInputSafeBackdropFilter } from '../utils/appearance'; + type RedisWorkbenchThemeInput = { darkMode: boolean; opacity: number; blur: number; + disableBackdropFilter?: boolean; }; type RedisWorkbenchTheme = { @@ -43,10 +46,15 @@ export const buildRedisWorkbenchTheme = ({ darkMode, opacity, blur, + disableBackdropFilter, }: RedisWorkbenchThemeInput): RedisWorkbenchTheme => { const normalizedOpacity = clamp(opacity, 0.1, 1); const normalizedBlur = Math.max(0, Math.round(blur)); const isTranslucent = normalizedOpacity < 0.999 || normalizedBlur > 0; + const backdropFilter = resolveTextInputSafeBackdropFilter( + normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none', + disableBackdropFilter ?? false, + ); if (darkMode) { const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92; @@ -84,7 +92,7 @@ export const buildRedisWorkbenchTheme = ({ treeSelectedBorder: 'rgba(246, 196, 83, 0.24)', divider: 'rgba(255, 255, 255, 0.07)', shadow: '0 20px 48px rgba(0, 0, 0, 0.26)', - backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none', + backdropFilter, }; } @@ -122,7 +130,7 @@ export const buildRedisWorkbenchTheme = ({ treeSelectedBorder: 'rgba(22, 119, 255, 0.18)', divider: 'rgba(15, 23, 42, 0.08)', shadow: '0 22px 52px rgba(15, 23, 42, 0.08)', - backdropFilter: normalizedBlur > 0 ? `blur(${normalizedBlur}px)` : 'none', + backdropFilter, }; }; diff --git a/frontend/src/utils/appearance.test.ts b/frontend/src/utils/appearance.test.ts index 89f19f0..de10e47 100644 --- a/frontend/src/utils/appearance.test.ts +++ b/frontend/src/utils/appearance.test.ts @@ -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'); + }); }); diff --git a/frontend/src/utils/appearance.ts b/frontend/src/utils/appearance.ts index 77c5aaa..19adab9 100644 --- a/frontend/src/utils/appearance.ts +++ b/frontend/src/utils/appearance.ts @@ -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; +}; diff --git a/frontend/src/utils/inputAutoCap.test.ts b/frontend/src/utils/inputAutoCap.test.ts new file mode 100644 index 0000000..98a57dc --- /dev/null +++ b/frontend/src/utils/inputAutoCap.test.ts @@ -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 = {}; + const textareaAttributes: Record = {}; + 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 = {}; + const textareaAttributes: Record = {}; + 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'); + }); +}); diff --git a/frontend/src/utils/inputAutoCap.ts b/frontend/src/utils/inputAutoCap.ts new file mode 100644 index 0000000..589f0ea --- /dev/null +++ b/frontend/src/utils/inputAutoCap.ts @@ -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); + }); +}; diff --git a/frontend/src/utils/overlayWorkbenchTheme.test.ts b/frontend/src/utils/overlayWorkbenchTheme.test.ts index 0a26027..68fa64d 100644 --- a/frontend/src/utils/overlayWorkbenchTheme.test.ts +++ b/frontend/src/utils/overlayWorkbenchTheme.test.ts @@ -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'); + }); }); diff --git a/frontend/src/utils/overlayWorkbenchTheme.ts b/frontend/src/utils/overlayWorkbenchTheme.ts index 9fd09f1..cfd95b8 100644 --- a/frontend/src/utils/overlayWorkbenchTheme.ts +++ b/frontend/src/utils/overlayWorkbenchTheme.ts @@ -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)', diff --git a/frontend/src/utils/redisSearchPattern.test.ts b/frontend/src/utils/redisSearchPattern.test.ts new file mode 100644 index 0000000..758a4e2 --- /dev/null +++ b/frontend/src/utils/redisSearchPattern.test.ts @@ -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, + }); + }); +}); diff --git a/frontend/src/utils/redisSearchPattern.ts b/frontend/src/utils/redisSearchPattern.ts new file mode 100644 index 0000000..041687b --- /dev/null +++ b/frontend/src/utils/redisSearchPattern.ts @@ -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 === '', + }; +}; diff --git a/frontend/src/utils/tabDisplay.test.ts b/frontend/src/utils/tabDisplay.test.ts new file mode 100644 index 0000000..77e80c6 --- /dev/null +++ b/frontend/src/utils/tabDisplay.test.ts @@ -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'); + }); +}); diff --git a/frontend/src/utils/tabDisplay.ts b/frontend/src/utils/tabDisplay.ts new file mode 100644 index 0000000..b834a15 --- /dev/null +++ b/frontend/src/utils/tabDisplay.ts @@ -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}`; +};