🎨 style(redis): 扁平化工作台并修正分隔与悬浮细节

- 将 V2 Redis 工作台改为连续网格布局并移除卡牌边框与阴影
- 让左右面板分隔线跨行对齐并同步网格宽度变量
- 扩大分隔线拖动热区且保持视觉线宽为 1px
- 优化长 Key 省略布局和悬浮查看触发区域

Refs #727
This commit is contained in:
Syngnat
2026-07-27 17:22:06 +08:00
parent 1bede52595
commit 0e06c74b54
5 changed files with 209 additions and 80 deletions

View File

@@ -113,6 +113,8 @@ describe('RedisResizableDivider interaction cleanup', () => {
});
it('removes the full-screen overlay and commits the width when the window blurs', () => {
expect(renderer?.root.findByType('div').props.className).toBe('redis-resizable-divider');
startResize();
expect(overlays).toHaveLength(1);
@@ -142,6 +144,33 @@ describe('RedisResizableDivider interaction cleanup', () => {
expect(onResizeEnd).toHaveBeenCalledWith(420);
});
it('updates a container grid width variable while dragging', () => {
const setProperty = vi.fn();
target.parentElement = { offsetWidth: 1200, style: { setProperty } } as any;
act(() => {
renderer?.unmount();
renderer = create(
<RedisResizableDivider
targetRef={{ current: target } as React.RefObject<HTMLDivElement>}
onResizeEnd={onResizeEnd}
maxReservedWidth={361}
containerWidthCssVariable="--redis-sidebar-width"
title="resize"
/>,
);
});
startResize();
act(() => fakeDocument.dispatch('mousemove', {
buttons: 1,
clientX: 520,
preventDefault: vi.fn(),
}));
expect(setProperty).toHaveBeenCalledWith('--redis-sidebar-width', '540px');
expect(target.style.width).toBe('');
});
it('removes the overlay without updating state when unmounted mid-resize', () => {
startResize();

View File

@@ -4,6 +4,8 @@ type RedisResizableDividerProps = {
onResizeEnd: (newWidth: number) => void;
targetRef: React.RefObject<HTMLDivElement>;
minWidth?: number;
maxReservedWidth?: number;
containerWidthCssVariable?: `--${string}`;
title: string;
};
@@ -12,6 +14,8 @@ const RedisResizableDivider: React.FC<RedisResizableDividerProps> = ({
onResizeEnd,
targetRef,
minWidth = 300,
maxReservedWidth = 350,
containerWidthCssVariable,
title,
}) => {
const abortInteractionRef = useRef<(() => void) | null>(null);
@@ -31,8 +35,9 @@ const RedisResizableDivider: React.FC<RedisResizableDividerProps> = ({
const startX = event.clientX;
const startWidth = target.offsetWidth;
const containerWidth = target.parentElement?.offsetWidth || window.innerWidth;
const maxWidth = containerWidth - 350;
const container = target.parentElement;
const containerWidth = container?.offsetWidth || window.innerWidth;
const maxWidth = containerWidth - maxReservedWidth;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;cursor:col-resize;z-index:9999;';
document.body.appendChild(overlay);
@@ -63,8 +68,12 @@ const RedisResizableDivider: React.FC<RedisResizableDividerProps> = ({
moveEvent.preventDefault();
const delta = moveEvent.clientX - startX;
currentWidth = Math.max(minWidth, Math.min(maxWidth, startWidth + delta));
target.style.width = `${currentWidth}px`;
target.style.flexBasis = `${currentWidth}px`;
if (containerWidthCssVariable && container) {
container.style.setProperty(containerWidthCssVariable, `${currentWidth}px`);
} else {
target.style.width = `${currentWidth}px`;
target.style.flexBasis = `${currentWidth}px`;
}
};
abortInteractionRef.current = abortInteraction;
@@ -75,6 +84,7 @@ const RedisResizableDivider: React.FC<RedisResizableDividerProps> = ({
return (
<div
className="redis-resizable-divider"
onMouseDown={handleMouseDown}
style={{
width: 5,

View File

@@ -341,13 +341,18 @@ describe('RedisViewer tree interactions', () => {
await flushEffects();
const header = renderer!.root.findByProps({ className: 'redis-key-detail-header' });
const top = renderer!.root.findByProps({ className: 'redis-key-detail-top' });
const viewMode = renderer!.root.findByProps({ className: 'redis-key-view-mode' });
const summary = renderer!.root.findByProps({ className: 'redis-key-detail-summary' });
const identity = renderer!.root.findByProps({ className: 'redis-key-detail-identity' });
const metadata = renderer!.root.findByProps({ className: 'redis-key-detail-metadata' });
const actions = renderer!.root.findByProps({ className: 'redis-key-detail-actions' });
expect(header.props.style).toMatchObject({ flexDirection: 'column' });
expect(header.parent).toBe(top);
expect(viewMode.parent).toBe(top);
expect(summary.props.style).toMatchObject({ minWidth: 0, width: '100%' });
expect(identity.props.style).toMatchObject({ minWidth: 0, width: '100%' });
expect(identity.parent).toBe(summary);
expect(metadata.parent).toBe(summary);
expect(actions.parent).toBe(summary);
@@ -358,6 +363,9 @@ describe('RedisViewer tree interactions', () => {
flexWrap: 'wrap',
maxWidth: '100%',
});
const activeKey = renderer!.root.findByProps({ 'data-redis-active-key': 'true' });
expect(activeKey.props.style).toMatchObject({ flex: '0 1 auto', minWidth: 0, textOverflow: 'ellipsis' });
expect(activeKey.props.style).not.toHaveProperty('maxWidth');
expect(findButtonByText(renderer!, 'Set TTL')).toBeTruthy();
expect(findButtonByText(renderer!, 'Refresh')).toBeTruthy();
expect(findButtonByText(renderer!, 'Delete Key')).toBeTruthy();

View File

@@ -2131,58 +2131,60 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<div className={isV2Ui ? 'gn-v2-redis-value-layout' : undefined} style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className={`redis-key-detail-header${isV2Ui ? ' gn-v2-redis-value-header' : ''}`} style={{ ...workbenchCardStyle, padding: 18, display: 'flex', flexDirection: 'column', gap: 16, flexShrink: 0 }}>
<div className="redis-key-detail-summary" style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0, width: '100%' }}>
<span style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>
{tr('redis_viewer.title.active_key')}
</span>
<div className="redis-key-detail-identity" style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<Tooltip title={selectedKey}>
<strong style={{ maxWidth: 340, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 26, color: workbenchTheme.textPrimary }}>
{selectedKey}
</strong>
</Tooltip>
<Tooltip title={tr('redis_viewer.tooltip.copy_key_name')}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
style={{ padding: '0 4px', display: 'flex', alignItems: 'center', color: workbenchTheme.textMuted }}
onClick={() => {
navigator.clipboard.writeText(selectedKey).then(() => {
message.success(tr('redis_viewer.message.key_name_copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}}
/>
</Tooltip>
</div>
<div className="redis-key-detail-metadata" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 }}>
<Tag color={getTypeColor(keyValue.type)} style={pillTagStyle}>{keyValue.type}</Tag>
<Tag icon={<ClockCircleOutlined />} style={mutedPillTagStyle}>{formatTTL(keyValue.ttl)}</Tag>
{keyValue.length > 0 && <Tag style={mutedPillTagStyle}>{tr('redis_viewer.label.length', { count: keyValue.length })}</Tag>}
</div>
<div className="redis-key-detail-actions" style={{ display: 'flex', gap: 4, alignItems: 'center', alignSelf: 'flex-start', flexWrap: 'wrap', maxWidth: '100%' }}>
<Button size="small" style={actionButtonStyle} onClick={() => {
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
setTtlModalOpen(true);
}}>{tr('redis_viewer.action.set_ttl')}</Button>
<Button size="small" style={actionButtonStyle} onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}>{tr('redis_viewer.action.refresh')}</Button>
<Popconfirm title={tr('redis_viewer.confirm.delete_key', { key: selectedKey })} onConfirm={handleDeleteCurrentKey}>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />}>{tr('redis_viewer.action.delete_key')}</Button>
</Popconfirm>
<div className={`redis-key-detail-top${isV2Ui ? ' gn-v2-redis-value-top' : ''}`} style={{ display: 'flex', flexDirection: 'column', gap: 12, flexShrink: 0 }}>
<div className={`redis-key-detail-header${isV2Ui ? ' gn-v2-redis-value-header' : ''}`} style={{ ...workbenchCardStyle, padding: 18, display: 'flex', flexDirection: 'column', gap: 16, flexShrink: 0 }}>
<div className="redis-key-detail-summary" style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0, width: '100%' }}>
<span style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>
{tr('redis_viewer.title.active_key')}
</span>
<div className="redis-key-detail-identity" style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, width: '100%' }}>
<Tooltip title={selectedKey}>
<strong data-redis-active-key="true" style={{ flex: '0 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 26, color: workbenchTheme.textPrimary }}>
{selectedKey}
</strong>
</Tooltip>
<Tooltip title={tr('redis_viewer.tooltip.copy_key_name')}>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
style={{ padding: '0 4px', display: 'flex', alignItems: 'center', color: workbenchTheme.textMuted, flex: '0 0 auto' }}
onClick={() => {
navigator.clipboard.writeText(selectedKey).then(() => {
message.success(tr('redis_viewer.message.key_name_copied'));
}).catch(() => {
message.error(tr('redis_viewer.message.copy_failed'));
});
}}
/>
</Tooltip>
</div>
<div className="redis-key-detail-metadata" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', minWidth: 0 }}>
<Tag color={getTypeColor(keyValue.type)} style={pillTagStyle}>{keyValue.type}</Tag>
<Tag icon={<ClockCircleOutlined />} style={mutedPillTagStyle}>{formatTTL(keyValue.ttl)}</Tag>
{keyValue.length > 0 && <Tag style={mutedPillTagStyle}>{tr('redis_viewer.label.length', { count: keyValue.length })}</Tag>}
</div>
<div className="redis-key-detail-actions" style={{ display: 'flex', gap: 4, alignItems: 'center', alignSelf: 'flex-start', flexWrap: 'wrap', maxWidth: '100%' }}>
<Button size="small" style={actionButtonStyle} onClick={() => {
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
setTtlModalOpen(true);
}}>{tr('redis_viewer.action.set_ttl')}</Button>
<Button size="small" style={actionButtonStyle} onClick={() => loadKeyValue(selectedKey)} icon={<ReloadOutlined />}>{tr('redis_viewer.action.refresh')}</Button>
<Popconfirm title={tr('redis_viewer.confirm.delete_key', { key: selectedKey })} onConfirm={handleDeleteCurrentKey}>
<Button size="small" style={dangerActionButtonStyle} icon={<DeleteOutlined />}>{tr('redis_viewer.action.delete_key')}</Button>
</Popconfirm>
</div>
</div>
</div>
</div>
<div className={isV2Ui ? 'gn-v2-redis-view-mode' : undefined} style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={{ paddingInline: 10, fontSize: 12, color: workbenchTheme.textMuted }}>{tr('redis_viewer.view.title')}</span>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto">{tr('redis_viewer.view.auto')}</Radio.Button>
<Radio.Button value="text">{tr('redis_viewer.view.text')}</Radio.Button>
<Radio.Button value="utf8">UTF-8</Radio.Button>
<Radio.Button value="hex">{tr('redis_viewer.view.hex')}</Radio.Button>
</Radio.Group>
<div className={`redis-key-view-mode${isV2Ui ? ' gn-v2-redis-view-mode' : ''}`} style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={{ paddingInline: 10, fontSize: 12, color: workbenchTheme.textMuted }}>{tr('redis_viewer.view.title')}</span>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto">{tr('redis_viewer.view.auto')}</Radio.Button>
<Radio.Button value="text">{tr('redis_viewer.view.text')}</Radio.Button>
<Radio.Button value="utf8">UTF-8</Radio.Button>
<Radio.Button value="hex">{tr('redis_viewer.view.hex')}</Radio.Button>
</Radio.Group>
</div>
</div>
<div className={isV2Ui ? 'gn-v2-redis-value-card' : undefined} style={{ ...workbenchCardStyle, padding: 14, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', height: '100%' }}>
@@ -2203,7 +2205,19 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
}
return (
<div className={`redis-viewer-workbench${isV2Ui ? ' gn-v2-redis-workbench' : ''}`} style={{ display: 'flex', height: '100%', gap: 12, padding: 12, background: workbenchTheme.appBg, backdropFilter: workbenchBackdropFilter, WebkitBackdropFilter: workbenchBackdropFilter }}>
<div
className={`redis-viewer-workbench${isV2Ui ? ' gn-v2-redis-workbench' : ''}`}
style={{
display: 'flex',
height: '100%',
gap: 12,
padding: 12,
background: workbenchTheme.appBg,
backdropFilter: workbenchBackdropFilter,
WebkitBackdropFilter: workbenchBackdropFilter,
'--gn-redis-sidebar-width': typeof leftPanelWidth === 'number' ? `${leftPanelWidth}px` : leftPanelWidth,
} as React.CSSProperties}
>
{/* Left: Key List */}
<div ref={leftPanelRef} className={isV2Ui ? 'gn-v2-redis-sidebar' : undefined} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div className={isV2Ui ? 'gn-v2-redis-header' : undefined} style={{ ...workbenchCardStyle, padding: 12 }}>
@@ -2332,12 +2346,18 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</div>
{/* Resizable Divider */}
<RedisResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} title={tr('redis_viewer.tooltip.resize_panels')} />
<RedisResizableDivider
targetRef={leftPanelRef}
onResizeEnd={setLeftPanelWidth}
maxReservedWidth={isV2Ui ? 361 : undefined}
containerWidthCssVariable={isV2Ui ? '--gn-redis-sidebar-width' : undefined}
title={tr('redis_viewer.tooltip.resize_panels')}
/>
{/* Right: Value Viewer */}
<div className={isV2Ui ? 'gn-v2-redis-value-pane' : undefined} style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
{valueLoading ? (
<div style={{ ...workbenchCardStyle, padding: 20, textAlign: 'center', color: workbenchTheme.textMuted }}>{tr('common.loading')}...</div>
<div className={isV2Ui ? 'gn-v2-redis-empty-value' : undefined} style={{ ...workbenchCardStyle, padding: 20, textAlign: 'center', color: workbenchTheme.textMuted }}>{tr('common.loading')}...</div>
) : (
renderValueEditor()
)}

View File

@@ -740,14 +740,23 @@ body[data-ui-version="v2"] .gn-v2-table-designer .ant-table-container {
/* ─── V2 Redis workbench ─ */
body[data-ui-version="v2"] .gn-v2-redis-workbench {
gap: 10px !important;
padding: 10px !important;
background: var(--gn-bg-app) !important;
display: grid !important;
grid-template-columns: clamp(320px, var(--gn-redis-sidebar-width, 50%), calc(100% - 361px)) 1px minmax(360px, 1fr);
grid-template-rows: max-content minmax(0, 1fr);
gap: 0 !important;
padding: 0 !important;
background: var(--gn-bg-panel) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-sidebar {
min-width: 320px !important;
gap: 10px !important;
display: grid !important;
grid-column: 1;
grid-row: 1 / 3;
grid-template-rows: subgrid;
width: 100% !important;
min-width: 0 !important;
gap: 0 !important;
background: var(--gn-bg-panel) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-header,
@@ -755,14 +764,18 @@ body[data-ui-version="v2"] .gn-v2-redis-tree-card,
body[data-ui-version="v2"] .gn-v2-redis-value-header,
body[data-ui-version="v2"] .gn-v2-redis-value-card,
body[data-ui-version="v2"] .gn-v2-redis-empty-value {
border-radius: 8px !important;
border: 0.5px solid var(--gn-br-1) !important;
border: 0 !important;
border-radius: 0 !important;
background: var(--gn-bg-panel) !important;
box-shadow: var(--gn-shadow-card) !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
body[data-ui-version="v2"] .gn-v2-redis-header {
padding: 12px !important;
grid-row: 1;
padding: 14px !important;
border-bottom: 1px solid var(--gn-br-1) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-toolbar {
@@ -770,13 +783,17 @@ body[data-ui-version="v2"] .gn-v2-redis-toolbar {
}
body[data-ui-version="v2"] .gn-v2-redis-tree-card {
padding: 8px !important;
grid-row: 2;
padding: 10px 12px 12px !important;
}
body[data-ui-version="v2"] .gn-v2-redis-tree-shell {
border-radius: 7px !important;
border: 0.5px solid var(--gn-br-1) !important;
background: var(--gn-bg-panel-2) !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
body[data-ui-version="v2"] .gn-v2-redis-workbench .ant-tree-checkbox {
@@ -784,15 +801,31 @@ body[data-ui-version="v2"] .gn-v2-redis-workbench .ant-tree-checkbox {
}
body[data-ui-version="v2"] .gn-v2-redis-value-pane {
display: contents !important;
min-width: 360px !important;
background: var(--gn-bg-panel) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-value-layout {
gap: 10px !important;
display: contents !important;
gap: 0 !important;
background: var(--gn-bg-panel) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-value-top {
display: flex;
grid-column: 3;
grid-row: 1;
height: 100%;
min-height: 0;
gap: 0 !important;
background: var(--gn-bg-panel);
}
body[data-ui-version="v2"] .gn-v2-redis-value-header {
padding: 14px !important;
flex: 1 1 auto !important;
padding: 14px 16px !important;
border-bottom: 0 !important;
}
body[data-ui-version="v2"] .gn-v2-redis-value-header strong {
@@ -804,22 +837,51 @@ body[data-ui-version="v2"] .gn-v2-redis-value-actions,
body[data-ui-version="v2"] .gn-v2-redis-view-mode,
body[data-ui-version="v2"] .gn-v2-redis-value-subtoolbar,
body[data-ui-version="v2"] .gn-v2-redis-value-actionbar {
border-radius: 7px !important;
border: 0.5px solid var(--gn-br-1) !important;
background: var(--gn-bg-panel-2) !important;
border: 0 !important;
border-radius: 0 !important;
background: var(--gn-bg-panel) !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-redis-value-subtoolbar {
min-height: 30px;
border-width: 0 0 0.5px !important;
border-radius: 0 !important;
border-bottom: 1px solid var(--gn-br-1) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-value-actionbar {
padding: 8px !important;
padding: 10px 12px !important;
margin-bottom: 0 !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
border-bottom: 1px solid var(--gn-br-1) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-view-mode {
padding: 8px 16px !important;
border-bottom: 1px solid var(--gn-br-1) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-value-card {
grid-column: 3;
grid-row: 2;
padding: 0 !important;
}
body[data-ui-version="v2"] .gn-v2-redis-empty-value {
grid-column: 3;
grid-row: 1 / 3;
}
body[data-ui-version="v2"] .gn-v2-redis-workbench > .redis-resizable-divider {
grid-column: 2;
grid-row: 1 / 3;
position: relative;
width: 1px !important;
background: var(--gn-br-1) !important;
}
body[data-ui-version="v2"] .gn-v2-redis-workbench > .redis-resizable-divider::before {
position: absolute;
inset: 0 -5px;
content: '';
}
body[data-ui-version="v2"] .gn-v2-redis-data-section .ant-table-wrapper {