🐛 fix(connection): 修复连接颜色重启丢失并同步标签页展示

- 恢复连接清洗流程中的图标类型与颜色字段
- 标签页增加连接色标识,便于区分多连接会话
- 抽取连接视觉解析并补充回归测试
Refs #334
This commit is contained in:
Syngnat
2026-04-26 19:33:12 +08:00
parent 2b340f3136
commit 55829bce86
6 changed files with 158 additions and 3 deletions

View File

@@ -48,6 +48,7 @@ import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { noAutoCapInputProps } from '../utils/inputAutoCap';
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
import { resolveConnectionAccentColor, resolveConnectionIconType } from '../utils/connectionVisual';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
@@ -358,10 +359,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const buildConnectionNode = (conn: SavedConnection): TreeNode => {
const existing = prevMap.get(conn.id);
const iconType = resolveConnectionIconType(conn);
const iconColor = resolveConnectionAccentColor(conn);
return {
title: conn.name,
key: conn.id,
icon: getDbIcon(conn.iconType || conn.config.type, conn.iconColor, 22),
icon: getDbIcon(iconType, iconColor, 22),
type: 'connection',
dataRef: conn,
isLeaf: false,

View File

@@ -23,24 +23,33 @@ import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
type SortableTabLabelProps = {
displayTitle: string;
menuItems: MenuProps['items'];
accentColor?: string;
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
displayTitle,
menuItems,
accentColor,
}) => {
const labelStyle = accentColor
? ({ '--connection-accent': accentColor } as React.CSSProperties)
: undefined;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className="tab-dnd-label"
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
onContextMenu={(e) => e.preventDefault()}
title={displayTitle}
style={labelStyle}
>
{displayTitle}
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
<span className="tab-title-text">{displayTitle}</span>
</span>
</Dropdown>
);
@@ -188,6 +197,7 @@ const TabManager: React.FC = () => {
const items = useMemo(() => tabs.map((tab, index) => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayTitle = buildTabDisplayTitle(tab, connection);
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
@@ -253,6 +263,7 @@ const TabManager: React.FC = () => {
<SortableTabLabel
displayTitle={displayTitle}
menuItems={menuItems}
accentColor={accentColor}
/>
),
key: tab.id,
@@ -317,8 +328,26 @@ const TabManager: React.FC = () => {
-webkit-user-select: none;
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
}
.main-tabs .tab-dnd-label.has-connection-accent {
position: relative;
}
.main-tabs .tab-connection-accent {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--connection-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--connection-accent) 22%, transparent);
flex: 0 0 auto;
}
.main-tabs .tab-title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-tabs .tab-dnd-node.is-dragging,
.main-tabs .tab-dnd-node.is-dragging .tab-dnd-label {
cursor: grabbing !important;

View File

@@ -164,6 +164,29 @@ describe('store appearance persistence', () => {
});
});
it('preserves connection icon metadata when replacing saved connections', async () => {
const { useStore } = await importStore();
useStore.getState().replaceConnections([
{
id: 'visual-1',
name: 'Visual Orders',
iconType: 'postgres',
iconColor: '#2f855a',
config: {
id: 'visual-1',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
},
},
]);
expect(useStore.getState().connections[0]?.iconType).toBe('postgres');
expect(useStore.getState().connections[0]?.iconColor).toBe('#2f855a');
});
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
storage.setItem('lite-db-storage', JSON.stringify({
state: {

View File

@@ -243,6 +243,18 @@ const sanitizeAddressList = (value: unknown): string[] => {
return all.slice(0, MAX_HOST_ENTRIES);
};
const sanitizeConnectionIconType = (value: unknown): string | undefined => {
const iconType = toTrimmedString(value).toLowerCase();
return iconType || undefined;
};
const sanitizeConnectionIconColor = (value: unknown): string | undefined => {
const color = toTrimmedString(value);
return /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)
? color
: undefined;
};
const normalizeConnectionType = (value: unknown): string => {
const type = toTrimmedString(value).toLowerCase();
if (type === "doris") {
@@ -574,6 +586,8 @@ const sanitizeSavedConnection = (
includeDatabases.length > 0 ? includeDatabases : undefined,
includeRedisDatabases:
includeRedisDatabases.length > 0 ? includeRedisDatabases : undefined,
iconType: sanitizeConnectionIconType(raw.iconType),
iconColor: sanitizeConnectionIconColor(raw.iconColor),
};
};

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import type { SavedConnection } from '../types';
import {
resolveConnectionAccentColor,
resolveConnectionIconType,
} from './connectionVisual';
const baseConnection: SavedConnection = {
id: 'conn-1',
name: 'Orders',
config: {
id: 'conn-1',
type: 'mysql',
host: 'db.local',
port: 3306,
user: 'root',
},
};
describe('connectionVisual', () => {
it('uses custom icon metadata as the connection visual identity', () => {
const connection: SavedConnection = {
...baseConnection,
iconType: 'postgres',
iconColor: '#2f855a',
};
expect(resolveConnectionIconType(connection)).toBe('postgres');
expect(resolveConnectionAccentColor(connection)).toBe('#2f855a');
});
it('falls back to the data source default color when custom color is blank', () => {
expect(resolveConnectionIconType(baseConnection)).toBe('mysql');
expect(resolveConnectionAccentColor(baseConnection)).toBe('#00758F');
});
it('ignores invalid custom colors instead of rendering unsafe CSS values', () => {
const connection: SavedConnection = {
...baseConnection,
iconColor: 'url(javascript:alert(1))',
};
expect(resolveConnectionAccentColor(connection)).toBe('#00758F');
});
});

View File

@@ -0,0 +1,40 @@
import type { SavedConnection } from '../types';
import { getDbDefaultColor } from '../components/DatabaseIcons';
const HEX_COLOR_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
const toTrimmedString = (value: unknown): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return '';
};
export const normalizeConnectionIconColor = (value: unknown): string => {
const color = toTrimmedString(value);
return HEX_COLOR_PATTERN.test(color) ? color : '';
};
export const resolveConnectionIconType = (
connection?: Pick<SavedConnection, 'iconType' | 'config'> | null,
): string => {
const iconType = toTrimmedString(connection?.iconType).toLowerCase();
if (iconType) {
return iconType;
}
const configType = toTrimmedString(connection?.config?.type).toLowerCase();
return configType || 'custom';
};
export const resolveConnectionAccentColor = (
connection?: Pick<SavedConnection, 'iconColor' | 'iconType' | 'config'> | null,
): string => {
const iconColor = normalizeConnectionIconColor(connection?.iconColor);
if (iconColor) {
return iconColor;
}
return getDbDefaultColor(resolveConnectionIconType(connection));
};