mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-11 16:59:43 +08:00
🐛 fix(frontend): 修复 Redis 搜索匹配与输入交互体验
- Redis Key 搜索默认补全包含匹配并支持 ASCII 大小写不敏感 - Redis 标签页增加连接名与 host 摘要,区分同名 db 标签 - 抽取 inputAutoCap、redisSearchPattern、tabDisplay 共享工具并补充回归测试 - 覆盖连接配置、Redis 搜索、表设计、表概览和数据表筛选输入的自动纠正问题 - 在 macOS 文本输入面板关闭局部毛玻璃,修复输入法切换出现透明框
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<DataGridProps> = ({
|
||||
// Filter State
|
||||
const [filterConditions, setFilterConditions] = useState<GridFilterCondition[]>([]);
|
||||
const [nextFilterId, setNextFilterId] = useState(1);
|
||||
const filterPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextConditions = normalizeGridFilterConditions(appliedFilterConditions);
|
||||
@@ -2242,6 +2244,30 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
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<any[]>([]);
|
||||
|
||||
@@ -5135,7 +5161,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
</div>
|
||||
|
||||
{showFilter && (
|
||||
<div style={{
|
||||
<div ref={filterPanelRef} style={{
|
||||
padding: `${filterTopPadding}px ${panelPaddingX}px ${panelPaddingY}px ${panelPaddingX}px`,
|
||||
background: 'transparent',
|
||||
boxSizing: 'border-box',
|
||||
@@ -5184,6 +5210,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
|
||||
{cond.op === 'CUSTOM' ? (
|
||||
<Input.TextArea
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={cond.value}
|
||||
@@ -5192,6 +5219,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
/>
|
||||
) : isListOp(cond.op) ? (
|
||||
<Input.TextArea
|
||||
{...noAutoCapInputProps}
|
||||
style={{ flex: 1 }}
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
value={cond.value}
|
||||
@@ -5201,12 +5229,14 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
) : isBetweenOp(cond.op) ? (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 220 }}
|
||||
value={cond.value}
|
||||
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
|
||||
placeholder="开始值"
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 220 }}
|
||||
value={cond.value2 || ''}
|
||||
onChange={e => updateFilter(cond.id, 'value2', e.target.value)}
|
||||
@@ -5214,9 +5244,10 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
/>
|
||||
</>
|
||||
) : isNoValueOp(cond.op) ? (
|
||||
<Input style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
|
||||
<Input {...noAutoCapInputProps} style={{ width: 220 }} value="" disabled placeholder="无需输入值" />
|
||||
) : (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
style={{ width: 280 }}
|
||||
value={cond.value}
|
||||
onChange={e => updateFilter(cond.id, 'value', e.target.value)}
|
||||
|
||||
@@ -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<string>('');
|
||||
@@ -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<React.CSSProperties>(() => ({
|
||||
borderRadius: 18,
|
||||
|
||||
@@ -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<FindInDatabaseModalProps> = ({ 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;
|
||||
|
||||
@@ -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<RedisViewerProps> = ({ 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<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
|
||||
const [keys, setKeys] = useState<RedisKeyInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchPattern, setSearchPattern] = useState('*');
|
||||
const [cursor, setCursor] = useState<string>('0');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
@@ -467,13 +485,29 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ 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<HTMLInputElement>) => {
|
||||
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<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
title: '添加字段',
|
||||
content: (
|
||||
<Form id="add-hash-field-form" layout="vertical">
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" />
|
||||
</Form.Item>
|
||||
<Form.Item label="字段名" name="field" rules={[{ required: true }]}>
|
||||
<Input id="new-hash-field" {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
<Form.Item label="值" name="value" rules={[{ required: true }]}>
|
||||
<Input.TextArea id="new-hash-value" rows={4} />
|
||||
</Form.Item>
|
||||
@@ -1888,7 +1922,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label>ID(可选,默认 *):</label>
|
||||
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
|
||||
<Input id="new-stream-id" {...noAutoCapInputProps} placeholder="例如: * 或 1723110000000-0" />
|
||||
</div>
|
||||
<div>
|
||||
<label>字段 JSON:</label>
|
||||
@@ -2050,7 +2084,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: blurToFilter(blur), WebkitBackdropFilter: blurToFilter(blur) }}>
|
||||
<div className="redis-viewer-workbench" style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
|
||||
{/* Left: Key List */}
|
||||
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
|
||||
<div style={{ ...workbenchCardStyle, padding: 12 }}>
|
||||
@@ -2063,9 +2097,12 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
</div>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Search
|
||||
placeholder="搜索 Key (支持 * 通配符)"
|
||||
defaultValue="*"
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索 Key"
|
||||
value={searchInput}
|
||||
onChange={handleSearchInputChange}
|
||||
onSearch={handleSearch}
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
@@ -2177,7 +2214,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
>
|
||||
<Form form={newKeyForm} layout="vertical" initialValues={{ ttl: -1 }}>
|
||||
<Form.Item name="key" label="Key" rules={[{ required: true, message: '请输入 Key' }]}>
|
||||
<Input placeholder="key name" />
|
||||
<Input {...noAutoCapInputProps} placeholder="key name" />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
|
||||
<Input.TextArea rows={4} placeholder="value" />
|
||||
@@ -2207,7 +2244,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
|
||||
rules={[{ required: true, message: '请输入新的 Key 名称' }]}
|
||||
extra={renameTargetKey ? `原始 Key:${renameTargetKey}` : undefined}
|
||||
>
|
||||
<Input placeholder="new:key:name" />
|
||||
<Input {...noAutoCapInputProps} placeholder="new:key:name" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -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<TreeNode[]>([]);
|
||||
|
||||
@@ -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 }>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '8px 14px', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
ref={searchInputRef}
|
||||
placeholder="搜索..."
|
||||
onChange={onSearch}
|
||||
@@ -4026,7 +3992,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={createDbForm} layout="vertical">
|
||||
<Form.Item name="name" label="数据库名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
{/* Charset option could be added here */}
|
||||
</Form>
|
||||
@@ -4044,7 +4010,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameDbForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新数据库名称" rules={[{ required: true, message: '请输入新数据库名称' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -4061,7 +4027,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameTableForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新表名" rules={[{ required: true, message: '请输入新表名' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -4078,7 +4044,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
>
|
||||
<Form form={renameViewForm} layout="vertical">
|
||||
<Form.Item name="newName" label="新视图名" rules={[{ required: true, message: '请输入新视图名' }]}>
|
||||
<Input />
|
||||
<Input {...noAutoCapInputProps} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -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<SortableTabLabelProps> = ({
|
||||
<span
|
||||
className="tab-dnd-label"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
title="拖拽调整标签顺序"
|
||||
title={displayTitle}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 : (
|
||||
<Input value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
<Input {...noAutoCapInputProps} value={text} onChange={e => handleColumnChange(record._key, 'name', e.target.value)} variant="borderless" />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -2492,6 +2493,7 @@ END;`;
|
||||
{isNewTable && (
|
||||
<>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入表名"
|
||||
value={newTableName}
|
||||
onChange={e => setNewTableName(e.target.value)}
|
||||
@@ -2805,6 +2807,7 @@ END;`;
|
||||
已选择字段:{selectedColumns.length}
|
||||
</div>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="请输入目标表名"
|
||||
value={copyTableName}
|
||||
onChange={e => setCopyTableName(e.target.value)}
|
||||
@@ -2865,6 +2868,7 @@ END;`;
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={indexForm.kind === 'PRIMARY' ? '主键索引固定名称:PRIMARY' : '索引名(例如 idx_user_name)'}
|
||||
value={indexForm.name}
|
||||
onChange={(e) => setIndexForm(prev => ({ ...prev, name: e.target.value }))}
|
||||
@@ -2934,6 +2938,7 @@ END;`;
|
||||
>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="外键约束名(例如 fk_order_user)"
|
||||
value={foreignKeyForm.constraintName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, constraintName: e.target.value }))}
|
||||
@@ -2949,6 +2954,7 @@ END;`;
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="参考表(支持 db.table)"
|
||||
value={foreignKeyForm.refTableName}
|
||||
onChange={(e) => setForeignKeyForm(prev => ({ ...prev, refTableName: e.target.value }))}
|
||||
|
||||
@@ -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<TableOverviewProps> = ({ tab }) => {
|
||||
title: '重命名表',
|
||||
content: (
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
defaultValue={tableName}
|
||||
onChange={e => { newName = e.target.value; }}
|
||||
placeholder="输入新表名"
|
||||
@@ -417,6 +419,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder="搜索表名或注释..."
|
||||
prefix={<SearchOutlined style={{ color: textMuted }} />}
|
||||
value={searchText}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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