feat(ui): 完成新版 UI 全量改造

- 整体布局:按新版 UI 重构左侧导航、对象树、连接分组和右键菜单体系

- 数据视图:优化 DDL 侧栏、横向滚动、筛选输入、编辑入口和虚拟表格体验

- AI 面板:重构新版入口、输入区、模型选择、快捷键和悬浮布局

- 标签与快捷键:补齐 Tab 悬浮信息、复制交互和 Mac/Windows 快捷键配置

- 工程质量:新增 v2 主题样式、菜单组件、外观工具和回归测试覆盖
This commit is contained in:
Syngnat
2026-05-22 17:38:00 +08:00
parent 1d90aed187
commit 24d9db4c51
35 changed files with 11121 additions and 742 deletions

View File

@@ -10,7 +10,7 @@ import type {
JVMAIPlanContext,
JVMDiagnosticPlanContext,
} from '../types';
import { DownOutlined } from '@ant-design/icons';
import { DatabaseOutlined, DownOutlined, HistoryOutlined, TableOutlined, WarningOutlined } from '@ant-design/icons';
import './AIChatPanel.css';
import { AIChatHeader } from './ai/AIChatHeader';
@@ -29,6 +29,8 @@ import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit';
import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool';
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
import { toAIRequestMessage } from '../utils/aiMessagePayload';
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
import { isMacLikePlatform } from '../utils/appearance';
interface AIChatPanelProps {
width?: number;
@@ -230,6 +232,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const [panelWidth, setPanelWidth] = useState(width);
const [isResizing, setIsResizing] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -245,6 +248,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const aiChatHistory = useStore(state => state.aiChatHistory);
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
const appearance = useStore(state => state.appearance);
const createNewAISession = useStore(state => state.createNewAISession);
const addAIChatMessage = useStore(state => state.addAIChatMessage);
const updateAIChatMessage = useStore(state => state.updateAIChatMessage);
@@ -257,8 +261,17 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
const connections = useStore(state => state.connections);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const sqlLogs = useStore(state => state.sqlLogs);
const aiChatSessions = useStore(state => state.aiChatSessions);
const setAIActiveSessionId = useStore(state => state.setAIActiveSessionId);
const aiPanelVisible = useStore(state => state.aiPanelVisible);
const aiChatSendShortcutBinding = useStore(state => state.shortcutOptions.sendAIChatMessage);
const isV2Ui = appearance.uiVersion === 'v2';
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
state.shortcutOptions,
'sendAIChatMessage',
activeShortcutPlatform,
));
const getCurrentJVMPlanContext = useCallback((): JVMAIPlanContext | undefined => {
const state = useStore.getState();
@@ -476,6 +489,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
}, []);
useEffect(() => {
if (messages.length === 0) return;
messagesEndRef.current?.scrollIntoView({ behavior: sending ? 'auto' : 'smooth', block: 'end' });
}, [messages.length, sending]);
@@ -1516,7 +1530,9 @@ SELECT * FROM users WHERE status = 1;
}
animationFrameId = requestAnimationFrame(() => {
const delta = resizeStartX.current - e.clientX;
const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, 280), 700);
const minWidth = isV2Ui ? 300 : 280;
const maxWidth = isV2Ui ? 520 : 700;
const newWidth = Math.min(Math.max(resizeStartWidth.current + delta, minWidth), maxWidth);
dragWidthRef.current = newWidth;
// 仅更新 ghost 虚线位置,通过绝对定位规避重排
@@ -1552,7 +1568,7 @@ SELECT * FROM users WHERE status = 1;
document.body.style.userSelect = '';
document.body.style.pointerEvents = '';
};
}, [isResizing, onWidthChange]);
}, [isResizing, isV2Ui, onWidthChange]);
// 回推幽灵上下文:基于 get_tables 记录进行表级精确匹配useMemo 缓存,避免每帧重算)
const { inferredConnectionId, inferredDbName } = useMemo(() => {
@@ -1595,9 +1611,67 @@ SELECT * FROM users WHERE status = 1;
const ck = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default';
return (aiContexts[ck] || []).map(c => `${c.dbName}.${c.tableName}`);
}, [activeContext?.connectionId, activeContext?.dbName, aiContexts]);
const aiInsights = useMemo(() => {
const recentLogs = sqlLogs.slice(0, 24);
const slowest = recentLogs
.filter((log) => log.status === 'success')
.sort((a, b) => b.duration - a.duration)[0];
const errors = recentLogs.filter((log) => log.status === 'error');
const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length;
const contextCount = contextTableNames.length;
return [
{
tone: 'info',
title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构',
body: contextCount > 0
? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。`
: '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。',
},
{
tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent',
title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本',
body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。',
},
{
tone: errors.length > 0 ? 'warn' : 'info',
title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常',
body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL可直接让 AI 解释或优化。` : '暂无 SQL 日志。'),
},
{
tone: writeCount > 0 ? 'warn' : 'accent',
title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主',
body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。',
},
];
}, [contextTableNames, sqlLogs]);
const renderPanelHistoryList = () => {
const sessions = aiChatSessions.slice(0, 8);
if (sessions.length === 0) {
return <div className="gn-v2-ai-empty-note"></div>;
}
return sessions.map((session) => (
<button
key={session.id}
type="button"
className={`gn-v2-ai-history-card${session.id === sid ? ' is-active' : ''}`}
onClick={() => {
setAIActiveSessionId(session.id);
setActivePanelMode('chat');
}}
>
<span>
<HistoryOutlined />
<strong>{session.title || '新对话'}</strong>
</span>
<small>{new Date(session.updatedAt).toLocaleString(undefined, { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</small>
</button>
));
};
const effectivePanelMode = isV2Ui ? activePanelMode : 'chat';
return (
<div ref={panelRef} className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
{isResizing && panelRect.current && createPortal(
@@ -1622,53 +1696,96 @@ SELECT * FROM users WHERE status = 1;
mutedColor={mutedColor}
textColor={textColor}
overlayTheme={overlayTheme}
onHistoryClick={() => setHistoryOpen(true)}
onClear={createNewAISession}
isV2Ui={isV2Ui}
onHistoryClick={() => {
if (isV2Ui) {
setActivePanelMode('history');
} else {
setHistoryOpen(true);
}
}}
onClear={() => {
createNewAISession();
setActivePanelMode('chat');
}}
onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }}
onClose={onClose}
messages={messages}
sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'}
activeMode={effectivePanelMode}
onModeChange={(mode) => {
if (!isV2Ui) return;
setActivePanelMode(mode);
if (mode === 'history') {
setHistoryOpen(false);
}
}}
/>
<div className="ai-chat-messages" onScroll={handleScrollMessages}>
{messages.length === 0 ? (
<AIChatWelcome
overlayTheme={overlayTheme}
quickActionBg={quickActionBg}
quickActionBorder={quickActionBorder}
textColor={textColor}
mutedColor={mutedColor}
onQuickAction={(prompt: string, autoSend?: boolean) => {
setInput(prompt);
if (autoSend) {
// Use setTimeout to let setInput render, then trigger send
setTimeout(() => {
const el = textareaRef.current;
if (el) el.focus();
// Dispatch a synthetic enter to trigger handleSend
// Simpler: just call handleSend directly with the prompt
}, 50);
}
}}
contextTableNames={contextTableNames}
/>
) : (
messages.map(msg => (
<AIMessageBubble
key={msg.id}
msg={msg}
darkMode={darkMode}
{effectivePanelMode === 'chat' && (
messages.length === 0 ? (
<AIChatWelcome
overlayTheme={overlayTheme}
quickActionBg={quickActionBg}
quickActionBorder={quickActionBorder}
textColor={textColor}
onEdit={handleEditMessage}
onRetry={handleRetryMessage}
onDelete={handleDeleteMessage}
activeConnectionId={inferredConnectionId}
activeConnectionConfig={activeConnectionConfig}
activeDbName={inferredDbName}
allMessages={messages}
mutedColor={mutedColor}
onQuickAction={(prompt: string, autoSend?: boolean) => {
setInput(prompt);
if (autoSend) {
// Use setTimeout to let setInput render, then trigger send
setTimeout(() => {
const el = textareaRef.current;
if (el) el.focus();
// Dispatch a synthetic enter to trigger handleSend
// Simpler: just call handleSend directly with the prompt
}, 50);
}
}}
contextTableNames={contextTableNames}
isV2Ui={isV2Ui}
/>
))
) : (
messages.map(msg => (
<AIMessageBubble
key={msg.id}
msg={msg}
darkMode={darkMode}
overlayTheme={overlayTheme}
textColor={textColor}
onEdit={handleEditMessage}
onRetry={handleRetryMessage}
onDelete={handleDeleteMessage}
activeConnectionId={inferredConnectionId}
activeConnectionConfig={activeConnectionConfig}
activeDbName={inferredDbName}
allMessages={messages}
/>
))
)
)}
{effectivePanelMode === 'insights' && (
<div className="gn-v2-ai-insights-list">
{aiInsights.map((item) => (
<div className={`gn-v2-ai-insight-card tone-${item.tone}`} key={item.title}>
<span className="gn-v2-ai-insight-icon">
{item.tone === 'warn' ? <WarningOutlined /> : item.tone === 'accent' ? <DatabaseOutlined /> : <TableOutlined />}
</span>
<div>
<strong>{item.title}</strong>
<p>{item.body}</p>
</div>
</div>
))}
</div>
)}
{effectivePanelMode === 'history' && (
<div className="gn-v2-ai-history-list">
{renderPanelHistoryList()}
</div>
)}
@@ -1706,6 +1823,7 @@ SELECT * FROM users WHERE status = 1;
dynamicModels={dynamicModels}
loadingModels={loadingModels}
sendShortcutBinding={aiChatSendShortcutBinding}
shortcutPlatform={activeShortcutPlatform}
composerNotice={composerNotice}
onModelChange={handleModelChange}
onFetchModels={fetchDynamicModels}
@@ -1716,6 +1834,7 @@ SELECT * FROM users WHERE status = 1;
overlayTheme={overlayTheme}
contextUsageChars={contextUsageChars}
maxContextChars={getDynamicMaxContextChars(activeProvider?.model)}
isV2Ui={isV2Ui}
/>
<AIHistoryDrawer

View File

@@ -565,7 +565,7 @@ const ConnectionModal: React.FC<{
buildOverlayWorkbenchTheme(darkMode, {
disableBackdropFilter: disableLocalBackdropFilter,
}),
[darkMode, disableLocalBackdropFilter],
[darkMode, disableLocalBackdropFilter, appearance.uiVersion],
);
const tunnelSectionStyle: React.CSSProperties = {

View File

@@ -58,7 +58,7 @@ export default function ConnectionPackagePasswordModal({
confirmLoading={confirmLoading}
onOk={onConfirm}
onCancel={onCancel}
destroyOnClose={false}
destroyOnHidden={false}
maskClosable={false}
>
{isExportMode ? (

View File

@@ -26,6 +26,7 @@ const storeState = vi.hoisted(() => ({
enabled: true,
opacity: 1,
blur: 0,
uiVersion: 'v2',
showDataTableVerticalBorders: false,
dataTableDensity: 'comfortable',
},
@@ -74,7 +75,16 @@ vi.mock('../store', () => ({
vi.mock('../../wailsjs/go/app/App', () => backendApp);
vi.mock('@monaco-editor/react', () => ({
default: ({ value }: { value?: string }) => <pre>{value}</pre>,
default: (props: { value?: string; language?: string; theme?: string; options?: Record<string, unknown> }) => (
<div
data-monaco-editor="true"
data-language={props.language}
data-theme={props.theme}
data-read-only={String(Boolean(props.options?.readOnly))}
>
{props.value}
</div>
),
}));
vi.mock('./ImportPreviewModal', () => ({
@@ -83,6 +93,7 @@ vi.mock('./ImportPreviewModal', () => ({
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
ReloadOutlined: Icon,
ImportOutlined: Icon,
@@ -104,6 +115,10 @@ vi.mock('@ant-design/icons', () => {
RightOutlined: Icon,
RobotOutlined: Icon,
SearchOutlined: Icon,
TableOutlined: Icon,
DatabaseOutlined: Icon,
NodeIndexOutlined: Icon,
ThunderboltOutlined: Icon,
};
});
@@ -181,6 +196,20 @@ vi.mock('antd', () => {
Modal.useModal = () => [{ info: vi.fn(() => ({ destroy: vi.fn() })) }, null];
const passthrough = ({ children }: any) => <>{children}</>;
const Segmented = ({ value, options, onChange }: any) => (
<div data-segmented-value={value}>
{(options || []).map((option: any) => (
<button
key={option.value}
type="button"
data-segmented-option={option.value}
onClick={() => onChange?.(option.value)}
>
{option.label}
</button>
))}
</div>
);
return {
Table: () => <table />,
@@ -193,7 +222,7 @@ vi.mock('antd', () => {
Select: () => null,
Modal,
Checkbox: ({ checked, onChange }: any) => <input type="checkbox" checked={checked} onChange={onChange} />,
Segmented: () => null,
Segmented,
Tooltip: passthrough,
Popover: passthrough,
DatePicker: () => null,
@@ -518,4 +547,232 @@ describe('DataGrid DDL interactions', () => {
expect(textContent(renderer!.root)).not.toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.props['data-modal-title'] === 'DDL - orders')).toHaveLength(0);
});
it('switches the v2 footer field tab into the main fields view', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '字段信息').props.onClick();
});
const content = textContent(renderer!.root);
expect(content).toContain('FIELDS');
expect(content).toContain('2 个字段');
expect(content).toContain('id');
expect(content).toContain('name');
});
it('returns to the legacy table view when v2-only footer views are active during UI switch', async () => {
storeState.appearance.uiVersion = 'v2';
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '字段信息').props.onClick();
});
expect(textContent(renderer!.root)).toContain('FIELDS');
storeState.appearance.uiVersion = 'legacy';
await act(async () => {
renderer!.update(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1, name: 'alpha' }]}
columnNames={['id', 'name']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
const content = textContent(renderer!.root);
expect(content).not.toContain('FIELDS');
expect(content).not.toContain('gn-v2-data-grid-fields-view');
expect(content).toContain('数据预览');
expect(content).toContain('结果视图');
expect(content).toContain('字段信息');
});
it('renders the v2 footer DDL view with the Monaco SQL editor', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBShowCreateTable.mockResolvedValueOnce({
success: true,
data: 'CREATE TABLE users (`id` bigint)',
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await waitForEffects();
const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true');
expect(editors).toHaveLength(1);
expect(editors[0].props['data-language']).toBe('sql');
expect(editors[0].props['data-read-only']).toBe('true');
expect(textContent(editors[0])).toContain('CREATE TABLE users');
expect(renderer!.root.findAll((node) => node.type === 'pre' && textContent(node).includes('CREATE TABLE users'))).toHaveLength(0);
});
it('opens the v2 DDL view as a right sidebar while keeping the table visible', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBShowCreateTable.mockResolvedValueOnce({
success: true,
data: 'CREATE TABLE users (`id` bigint)',
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await waitForEffects();
await act(async () => {
renderer!.root.findByProps({ 'data-segmented-option': 'side' }).props.onClick();
});
const sideWorkspace = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
expect(sideWorkspace.props.className).toBe('gn-v2-data-grid-split-workspace');
expect(renderer!.root.findByProps({ 'aria-label': '表 DDL 侧栏' }).props.className).toBe('gn-v2-data-grid-ddl-sidebar');
expect(renderer!.root.findByProps({ 'data-grid-ddl-view': 'side' }).props.className).toContain('is-side');
expect(renderer!.root.findAllByType('table')).toHaveLength(1);
expect(sideWorkspace.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px');
expect(sideWorkspace.props.style['--gn-v2-ddl-sidebar-width']).toBe('420px');
expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(420);
const editors = renderer!.root.findAll((node) => node.props['data-monaco-editor'] === 'true');
expect(editors).toHaveLength(1);
expect(editors[0].props['data-language']).toBe('sql');
expect(textContent(editors[0])).toContain('CREATE TABLE users');
});
it('previews and commits the v2 DDL sidebar width after dragging the separator', async () => {
storeState.appearance.uiVersion = 'v2';
backendApp.DBShowCreateTable.mockResolvedValueOnce({
success: true,
data: 'CREATE TABLE users (`id` bigint)',
});
let renderer: ReactTestRenderer;
await act(async () => {
renderer = create(
<DataGrid
data={[{ __gonavi_row_key__: 'row-1', id: 1 }]}
columnNames={['id']}
loading={false}
tableName="users"
dbName="main"
connectionId="conn-1"
/>,
);
});
await waitForEffects();
await act(async () => {
findButton(renderer!, '查看 DDL').props.onClick();
});
await waitForEffects();
await act(async () => {
renderer!.root.findByProps({ 'data-segmented-option': 'side' }).props.onClick();
});
const container = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
expect(container.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px');
expect(renderer!.root.findByProps({ 'data-grid-ddl-resize-preview': 'true' }).props.className).toBe('gn-v2-data-grid-ddl-resize-preview');
const addEventListenerMock = vi.mocked(document.addEventListener);
const removeEventListenerMock = vi.mocked(document.removeEventListener);
const resizer = renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' });
await act(async () => {
resizer.props.onMouseDown({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
clientX: 900,
});
});
const mouseMoveHandler = addEventListenerMock.mock.calls.find(([eventName]) => eventName === 'mousemove')?.[1] as ((event: MouseEvent) => void) | undefined;
const mouseUpHandler = addEventListenerMock.mock.calls.find(([eventName]) => eventName === 'mouseup')?.[1] as (() => void) | undefined;
expect(mouseMoveHandler).toBeTypeOf('function');
expect(mouseUpHandler).toBeTypeOf('function');
await act(async () => {
mouseMoveHandler?.({ clientX: 780 } as MouseEvent);
});
const movingContainer = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
expect(movingContainer.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 420px');
expect(movingContainer.props.style['--gn-v2-ddl-sidebar-width']).toBe('420px');
expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(420);
await act(async () => {
mouseUpHandler?.();
});
const resizedContainer = renderer!.root.findByProps({ 'data-grid-ddl-layout': 'side' });
expect(resizedContainer.props.style.gridTemplateColumns).toBe('minmax(0, 1fr) 8px 540px');
expect(resizedContainer.props.style['--gn-v2-ddl-sidebar-width']).toBe('540px');
expect(renderer!.root.findByProps({ 'data-grid-ddl-resizer': 'true' }).props['aria-valuenow']).toBe(540);
expect(removeEventListenerMock).toHaveBeenCalledWith('mousemove', mouseMoveHandler);
expect(removeEventListenerMock).toHaveBeenCalledWith('mouseup', mouseUpHandler);
});
});

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -84,6 +85,12 @@ const createRows = (count: number) => Array.from({ length: count }, (_, i) => ({
}));
describe('DataViewer safe editing locator', () => {
it('memoizes the table data viewer so parent-only modal state does not repaint loaded data', () => {
const source = readFileSync(new URL('./DataViewer.tsx', import.meta.url), 'utf8');
expect(source).toContain('React.memo(({ tab, isActive = true }) => {');
});
const renderAndReload = async (tab: TabData = createTab()) => {
let renderer: ReactTestRenderer;
await act(async () => {

View File

@@ -255,6 +255,7 @@ type ViewerScrollSnapshot = {
};
const viewerFilterSnapshotsByTab = new Map<string, ViewerFilterSnapshot>();
const VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS = 160;
const normalizeViewerFilterConditions = (conditions: FilterCondition[] | undefined): FilterCondition[] => {
if (!Array.isArray(conditions)) return [];
@@ -289,7 +290,7 @@ const getViewerFilterSnapshot = (tabId: string): ViewerFilterSnapshot => {
};
};
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isActive = true }) => {
const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ tab, isActive = true }) => {
const initialViewerSnapshot = useMemo(() => getViewerFilterSnapshot(tab.id), [tab.id]);
const [data, setData] = useState<any[]>([]);
const [columnNames, setColumnNames] = useState<string[]>([]);
@@ -298,6 +299,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const [loading, setLoading] = useState(false);
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
const appearance = useStore(state => state.appearance);
const isV2Ui = appearance?.uiVersion === 'v2';
const fetchSeqRef = useRef(0);
const countSeqRef = useRef(0);
const countKeyRef = useRef<string>('');
@@ -318,6 +321,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
top: initialViewerSnapshot.scrollTop,
left: initialViewerSnapshot.scrollLeft,
});
const pendingScrollSnapshotPersistRef = useRef<ViewerScrollSnapshot | null>(null);
const scrollSnapshotPersistTimerRef = useRef<number | null>(null);
const initialLoadRef = useRef(false);
const skipNextAutoFetchRef = useRef(false);
@@ -375,7 +380,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
useEffect(() => {
return () => {
persistViewerSnapshot(tab.id);
if (scrollSnapshotPersistTimerRef.current !== null) {
window.clearTimeout(scrollSnapshotPersistTimerRef.current);
scrollSnapshotPersistTimerRef.current = null;
}
const pendingScrollSnapshot = pendingScrollSnapshotPersistRef.current;
pendingScrollSnapshotPersistRef.current = null;
persistViewerSnapshot(tab.id, pendingScrollSnapshot ? {
scrollTop: pendingScrollSnapshot.top,
scrollLeft: pendingScrollSnapshot.left,
} : undefined);
};
}, [tab.id, persistViewerSnapshot]);
@@ -412,10 +426,18 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
const handleTableScrollSnapshotChange = useCallback((snapshot: ViewerScrollSnapshot) => {
scrollSnapshotRef.current = snapshot;
persistViewerSnapshot(tab.id, {
scrollTop: snapshot.top,
scrollLeft: snapshot.left,
});
pendingScrollSnapshotPersistRef.current = snapshot;
if (scrollSnapshotPersistTimerRef.current !== null) return;
scrollSnapshotPersistTimerRef.current = window.setTimeout(() => {
scrollSnapshotPersistTimerRef.current = null;
const pendingScrollSnapshot = pendingScrollSnapshotPersistRef.current;
pendingScrollSnapshotPersistRef.current = null;
if (!pendingScrollSnapshot) return;
persistViewerSnapshot(tab.id, {
scrollTop: pendingScrollSnapshot.top,
scrollLeft: pendingScrollSnapshot.left,
});
}, VIEWER_SCROLL_SNAPSHOT_PERSIST_DELAY_MS);
}, [tab.id, persistViewerSnapshot]);
const handleManualTotalCount = useCallback(async () => {
@@ -1092,7 +1114,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
}, [tab.id, tab.connectionId, tab.dbName, tab.tableName, sortInfo, filterConditions, quickWhereCondition]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className={isV2Ui ? 'gn-v2-data-viewer' : undefined} style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}
@@ -1123,6 +1145,6 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAct
/>
</div>
);
};
});
export default DataViewer;

View File

@@ -216,7 +216,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG
const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity);
const driverManagerTheme = useMemo(
() => buildDriverManagerWorkbenchTheme(darkMode, opacity),
[darkMode, opacity],
[darkMode, opacity, appearance.uiVersion],
);
const [loading, setLoading] = useState(false);
const [downloadDir, setDownloadDir] = useState('');

View File

@@ -26,12 +26,20 @@ const storeState = vi.hoisted(() => ({
savedQueries: [] as SavedQuery[],
saveQuery: vi.fn(),
theme: 'light',
appearance: { uiVersion: 'legacy' as const },
sqlFormatOptions: { keywordCase: 'upper' as const },
setSqlFormatOptions: vi.fn(),
queryOptions: { maxRows: 5000 },
setQueryOptions: vi.fn(),
shortcutOptions: {
runQuery: { enabled: false, combo: '' },
runQuery: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
selectCurrentStatement: {
mac: { enabled: false, combo: '' },
windows: { enabled: false, combo: '' },
},
},
activeTabId: 'tab-1',
aiPanelVisible: false,

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Editor, { type OnMount } from './MonacoEditor';
import { Button, message, Modal, Input, Form, Dropdown, MenuProps, Tooltip, Select, Tabs } from 'antd';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined } from '@ant-design/icons';
import { PlayCircleOutlined, SaveOutlined, FormatPainterOutlined, SettingOutlined, CloseOutlined, StopOutlined, RobotOutlined, DatabaseOutlined } from '@ant-design/icons';
import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition, IndexDefinition } from '../types';
@@ -10,7 +10,7 @@ import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDat
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { applyMongoQueryAutoLimit, convertMongoShellToJsonCommand } from "../utils/mongodb";
import { getShortcutDisplay, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding } from "../utils/shortcuts";
import { getShortcutDisplayLabel, getShortcutPlatform, isEditableElement, isShortcutMatch, comboToMonacoKeyBinding, resolveShortcutBinding } from "../utils/shortcuts";
import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { isOracleLikeDialect, resolveSqlDialect, resolveSqlFunctions, resolveSqlKeywords } from '../utils/sqlDialect';
@@ -18,6 +18,7 @@ import { applyQueryAutoLimit } from '../utils/queryAutoLimit';
import { extractQueryResultTableRef, type QueryResultTableRef } from '../utils/queryResultTable';
import { quoteIdentPart } from '../utils/sql';
import { resolveCurrentSqlStatementRange } from '../utils/sqlStatementSelection';
import { isMacLikePlatform } from '../utils/appearance';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator } from '../utils/rowLocator';
@@ -669,12 +670,23 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const columnsCacheRef = useRef<Record<string, ColumnDefinition[]>>({});
const saveQuery = useStore(state => state.saveQuery);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const sqlFormatOptions = useStore(state => state.sqlFormatOptions);
const setSqlFormatOptions = useStore(state => state.setSqlFormatOptions);
const queryOptions = useStore(state => state.queryOptions);
const setQueryOptions = useStore(state => state.setQueryOptions);
const shortcutOptions = useStore(state => state.shortcutOptions);
const activeShortcutPlatform = getShortcutPlatform(isMacLikePlatform());
const runQueryShortcutBinding = useMemo(
() => resolveShortcutBinding(shortcutOptions, 'runQuery', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
);
const selectCurrentStatementShortcutBinding = useMemo(
() => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
);
const activeTabId = useStore(state => state.activeTabId);
const autoFetchVisible = useAutoFetchVisibility();
@@ -689,6 +701,16 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
return savedQueries.find((item) => item.id === tabId) || null;
}, [savedQueries, tab.id, tab.savedQueryId]);
const activeConnectionName = useMemo(
() => connections.find(c => c.id === currentConnectionId)?.name || '未选择连接',
[connections, currentConnectionId],
);
const queryResultSummary = useMemo(() => {
if (loading) return '执行中';
if (resultSets.length === 0) return executionError ? '执行失败' : '未执行';
const totalRows = resultSets.reduce((sum, rs) => sum + (Array.isArray(rs.rows) ? rs.rows.length : 0), 0);
return `${resultSets.length} 组结果 / ${totalRows.toLocaleString()}`;
}, [executionError, loading, resultSets]);
useEffect(() => {
currentConnectionIdRef.current = currentConnectionId;
@@ -960,7 +982,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
});
// Register runQuery shortcut inside Monaco so it overrides Monaco's default keybinding
const runBinding = shortcutOptions.runQuery;
const runBinding = runQueryShortcutBinding;
if (runBinding?.enabled && runBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
runBinding.combo, monaco.KeyMod, monaco.KeyCode
@@ -977,7 +999,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}
}
const selectStatementBinding = shortcutOptions.selectCurrentStatement;
const selectStatementBinding = selectCurrentStatementShortcutBinding;
if (selectStatementBinding?.enabled && selectStatementBinding.combo) {
const keyBinding = comboToMonacoKeyBinding(
selectStatementBinding.combo, monaco.KeyMod, monaco.KeyCode
@@ -2215,7 +2237,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}, [activeTabId, tab.id]);
useEffect(() => {
const binding = shortcutOptions.runQuery;
const binding = runQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) {
return;
}
@@ -2240,7 +2262,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return () => {
window.removeEventListener('keydown', handleRunShortcut, true);
};
}, [activeTabId, tab.id, shortcutOptions.runQuery, handleRun]);
}, [activeTabId, tab.id, runQueryShortcutBinding, handleRun]);
// Re-register Monaco internal keybinding when runQuery shortcut changes
useEffect(() => {
@@ -2253,7 +2275,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = shortcutOptions.runQuery;
const binding = runQueryShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
@@ -2274,7 +2296,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
runQueryActionRef.current = null;
}
};
}, [shortcutOptions.runQuery]);
}, [runQueryShortcutBinding]);
useEffect(() => {
if (selectCurrentStatementActionRef.current) {
@@ -2286,7 +2308,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const monaco = monacoRef.current;
if (!editor || !monaco) return;
const binding = shortcutOptions.selectCurrentStatement;
const binding = selectCurrentStatementShortcutBinding;
if (!binding?.enabled || !binding.combo) return;
const keyBinding = comboToMonacoKeyBinding(binding.combo, monaco.KeyMod, monaco.KeyCode);
@@ -2305,7 +2327,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
selectCurrentStatementActionRef.current = null;
}
};
}, [shortcutOptions.selectCurrentStatement]);
}, [selectCurrentStatementShortcutBinding, handleSelectCurrentStatement]);
useEffect(() => {
const handleRunActiveQuery = () => {
@@ -2505,7 +2527,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
return (
<div ref={queryEditorRootRef} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<div ref={queryEditorRootRef} className={isV2Ui ? 'gn-v2-query-editor' : undefined} style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
<style>{`
.query-result-tabs {
flex: 1 1 auto;
@@ -2549,8 +2571,20 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
transition: none !important;
}
`}</style>
<div ref={editorPaneRef}>
<div style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<div ref={editorPaneRef} className={isV2Ui ? 'gn-v2-query-editor-pane' : undefined}>
{isV2Ui && (
<div className="gn-v2-query-header">
<div className="gn-v2-query-title">
<span>SQL WORKSPACE</span>
<strong>{currentDb || '未选择数据库'}</strong>
</div>
<div className="gn-v2-query-context">
<span><DatabaseOutlined /> {activeConnectionName}</span>
<span>{queryResultSummary}</span>
</div>
</div>
)}
<div className={isV2Ui ? 'gn-v2-query-toolbar' : undefined} style={{ padding: '4px 8px 8px', display: 'flex', gap: '8px', flexShrink: 0, alignItems: 'center' }}>
<Select
style={{ width: 150 }}
placeholder="选择连接"
@@ -2587,9 +2621,9 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
<Button.Group>
<Tooltip
title={
shortcutOptions.runQuery?.enabled && shortcutOptions.runQuery?.combo
? `运行(${getShortcutDisplay(shortcutOptions.runQuery.combo)}`
: '运行'
runQueryShortcutBinding.enabled && runQueryShortcutBinding.combo
? `运行(${getShortcutDisplayLabel(runQueryShortcutBinding.combo, activeShortcutPlatform)}`
: '运行'
}
>
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleRun} loading={loading}>
@@ -2626,7 +2660,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
</Dropdown>
</div>
<div style={{ height: editorHeight, minHeight: '100px' }}>
<div className={isV2Ui ? 'gn-v2-query-monaco-shell' : undefined} style={{ height: editorHeight, minHeight: '100px' }}>
<Editor
height="100%"
defaultLanguage="sql"
@@ -2644,6 +2678,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
</div>
<div
className={isV2Ui ? 'gn-v2-query-resizer' : undefined}
onMouseDown={handleMouseDown}
style={{
height: '5px',
@@ -2656,7 +2691,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
/>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
<div className={isV2Ui ? 'gn-v2-query-results' : undefined} style={{ flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}>
{resultSets.length > 0 ? (
<Tabs
className="query-result-tabs"
@@ -2695,7 +2730,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
if (isAffectedResult) {
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
return (
<div style={{
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
flex: 1, minHeight: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
flexDirection: 'column', gap: 8, color: '#666', userSelect: 'text',
}}>
@@ -2727,7 +2762,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
}))}
/>
) : executionError ? (
<div style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
<div className={isV2Ui ? 'gn-v2-query-error' : undefined} style={{ flex: 1, minHeight: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 16, background: darkMode ? '#1e1e1e' : '#fafafa', overflow: 'auto' }}>
<div style={{ color: '#ff4d4f', fontWeight: 'bold', fontSize: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<CloseOutlined />
<span></span>
@@ -2756,7 +2791,14 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
</div>
</div>
) : (
<div style={{ flex: 1, minHeight: 0 }} />
<div className={isV2Ui ? 'gn-v2-query-empty' : undefined} style={{ flex: 1, minHeight: 0 }}>
{isV2Ui && (
<div>
<strong> SQL</strong>
<span></span>
</div>
)}
</div>
)}
</div>

View File

@@ -156,12 +156,13 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
const connection = connections.find(c => c.id === connectionId);
const workbenchTheme = useMemo(
() => buildRedisWorkbenchTheme({ darkMode, opacity, blur, disableBackdropFilter: disableLocalBackdropFilter }),
[blur, darkMode, disableLocalBackdropFilter, opacity],
[blur, darkMode, disableLocalBackdropFilter, opacity, appearance.uiVersion],
);
const workbenchBackdropFilter = useMemo(
() => resolveTextInputSafeBackdropFilter(blurToFilter(blur), disableLocalBackdropFilter),
[blur, disableLocalBackdropFilter],
);
const isV2Ui = appearance.uiVersion === 'v2';
const keyAccentColor = workbenchTheme.accent;
const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff';
const valueToolbarBg = workbenchTheme.panelBgStrong;
@@ -975,6 +976,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
if (!keyValue || !selectedKey) {
return (
<div
className={isV2Ui ? 'gn-v2-redis-empty-value' : undefined}
style={{
...workbenchCardStyle,
height: '100%',
@@ -997,7 +999,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
<div className={isV2Ui ? 'gn-v2-redis-value-subtoolbar' : undefined} style={{
padding: '4px 8px',
background: valueToolbarBg,
borderBottom: valueToolbarBorder,
@@ -1090,8 +1092,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加字段',
@@ -1228,8 +1230,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
@@ -1375,8 +1377,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加成员',
@@ -1489,8 +1491,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加成员',
@@ -1671,8 +1673,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className={isV2Ui ? 'gn-v2-redis-data-section' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加 Stream 消息',
@@ -1772,8 +1774,8 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 18, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexShrink: 0 }}>
<div className={isV2Ui ? 'gn-v2-redis-value-layout' : undefined} style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className={isV2Ui ? 'gn-v2-redis-value-header' : undefined} style={{ ...workbenchCardStyle, padding: 18, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<span style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>
Active Key
@@ -1804,7 +1806,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
{keyValue.length > 0 && <Tag style={mutedPillTagStyle}>: {keyValue.length}</Tag>}
</div>
</div>
<div style={{ ...workbenchSubCardStyle, padding: 4, display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<div className={isV2Ui ? 'gn-v2-redis-value-actions' : undefined} style={{ ...workbenchSubCardStyle, padding: 4, display: 'flex', gap: 4, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<Button size="small" style={actionButtonStyle} onClick={() => {
ttlForm.setFieldsValue({ ttl: keyValue.ttl > 0 ? keyValue.ttl : -1 });
setTtlModalOpen(true);
@@ -1815,7 +1817,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Popconfirm>
</div>
</div>
<div style={{ ...workbenchSubCardStyle, padding: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<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 }}></span>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto"></Radio.Button>
@@ -1824,7 +1826,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<Radio.Button value="hex"></Radio.Button>
</Radio.Group>
</div>
<div style={{ ...workbenchCardStyle, padding: 14, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<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%' }}>
{keyValue.type === 'string' && renderStringValue()}
{keyValue.type === 'hash' && renderHashValue()}
@@ -1843,10 +1845,10 @@ 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: 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 }}>
{/* Left: Key List */}
<div ref={leftPanelRef} style={{ width: leftPanelWidth, minWidth: 300, display: 'flex', flexDirection: 'column', flexShrink: 0, gap: 12 }}>
<div style={{ ...workbenchCardStyle, padding: 12 }}>
<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 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
<div>
<div style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '.08em', color: workbenchTheme.textMuted, fontWeight: 600 }}>Key Explorer</div>
@@ -1875,7 +1877,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
enterButton={<SearchOutlined />}
/>
</Space.Compact>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div className={isV2Ui ? 'gn-v2-redis-toolbar' : undefined} style={{ marginTop: 12, display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<Space wrap size={8}>
<Button size="small" style={actionButtonStyle} icon={<ReloadOutlined />} onClick={handleRefresh}></Button>
<Button size="small" style={actionButtonStyle} icon={<PlusOutlined />} onClick={() => setNewKeyModalOpen(true)}></Button>
@@ -1893,7 +1895,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Popconfirm>
</div>
</div>
<div style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
<div className={isV2Ui ? 'gn-v2-redis-tree-card' : undefined} style={{ ...workbenchCardStyle, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', padding: 10 }}>
{isLargeKeyspace && (
<div style={{ padding: '8px 10px', fontSize: 12, color: workbenchTheme.textMuted, marginBottom: 8, borderRadius: 12, background: workbenchTheme.panelBgSubtle, border: workbenchTheme.panelBorder }}>
{REDIS_LARGE_KEYSPACE_MAX_EXPANDED_GROUPS}
@@ -1903,7 +1905,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<span> / Key</span>
<span> / TTL</span>
</div>
<div ref={treeContainerRef} style={{ ...workbenchSubCardStyle, flex: 1, minHeight: 0, overflow: 'hidden', padding: 6 }}>
<div ref={treeContainerRef} className={isV2Ui ? 'gn-v2-redis-tree-shell' : undefined} style={{ ...workbenchSubCardStyle, flex: 1, minHeight: 0, overflow: 'hidden', padding: 6 }}>
<Spin spinning={loading} size="small" style={{ width: '100%' }}>
<Tree
blockNode
@@ -1939,7 +1941,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
<ResizableDivider targetRef={leftPanelRef} onResizeEnd={setLeftPanelWidth} />
{/* Right: Value Viewer */}
<div style={{ flex: 1, overflow: 'hidden', minWidth: 300 }}>
<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 }}>...</div>
) : (
@@ -2066,6 +2068,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
</Modal>
{treeContextMenu && typeof document !== 'undefined' && createPortal((
<div
className={isV2Ui ? 'gn-v2-context-menu gn-v2-redis-context-menu' : undefined}
style={{
position: 'fixed',
left: typeof window !== 'undefined' ? Math.min(treeContextMenu.x + 4, Math.max(16, window.innerWidth - 220)) : treeContextMenu.x,

View File

@@ -1,23 +1,43 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Sidebar, { resolveSidebarTableNameForCopy } from './Sidebar';
import Sidebar, {
buildV2RailConnectionGroups,
filterV2ExplorerTreeByKind,
getV2RailConnectionGroupBadgeText,
hasSidebarLazyChildren,
parseV2CommandSearchQuery,
resolveSidebarNodeConnectionId,
resolveV2ActiveConnectionId,
isSidebarTablePinned,
resolveSidebarTableNameForCopy,
shouldClearSidebarActiveContextOnEmptySelect,
shouldLoadSidebarNodeOnExpand,
sortSidebarTableEntries,
} from './Sidebar';
import { buildSidebarTablePinKey } from '../store';
import {
DEFAULT_SHORTCUT_OPTIONS,
cloneShortcutOptions,
} from '../utils/shortcuts';
import {
V2ConnectionGroupContextMenuView,
V2ConnectionContextMenuView,
V2DatabaseContextMenuView,
V2TableContextMenuView,
V2TableGroupContextMenuView,
formatV2TableContextMenuRows,
formatV2TableContextMenuSize,
} from './V2TableContextMenu';
const mocks = vi.hoisted(() => ({
noop: vi.fn(),
}));
vi.mock('../store', () => ({
useStore: (selector: (state: any) => any) => selector({
connections: [],
savedQueries: [],
externalSQLDirectories: [],
deleteQuery: mocks.noop,
saveExternalSQLDirectory: mocks.noop,
deleteExternalSQLDirectory: mocks.noop,
addConnection: mocks.noop,
addTab: mocks.noop,
state: {
connections: [] as any[],
activeContext: null as any,
activeTabId: 'conn-1-main-users',
tabs: [{
id: 'conn-1-main-users',
title: 'users',
@@ -25,11 +45,43 @@ vi.mock('../store', () => ({
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
}],
activeTabId: 'conn-1-main-users',
}] as any[],
connectionTags: [] as any[],
appearance: {
enabled: true,
opacity: 1,
blur: 0,
uiVersion: 'legacy',
} as any,
},
}));
vi.mock('../store', () => ({
buildSidebarTablePinKey: (
connectionId: string,
dbName: string,
tableName: string,
schemaName = '',
) => JSON.stringify([
connectionId.trim(),
dbName.trim(),
schemaName.trim(),
tableName.trim(),
]),
useStore: (selector: (state: any) => any) => selector({
connections: mocks.state.connections,
savedQueries: [],
externalSQLDirectories: [],
deleteQuery: mocks.noop,
saveExternalSQLDirectory: mocks.noop,
deleteExternalSQLDirectory: mocks.noop,
addConnection: mocks.noop,
addTab: mocks.noop,
tabs: mocks.state.tabs,
activeTabId: mocks.state.activeTabId,
setActiveContext: mocks.noop,
removeConnection: mocks.noop,
connectionTags: [],
connectionTags: mocks.state.connectionTags,
addConnectionTag: mocks.noop,
updateConnectionTag: mocks.noop,
removeConnectionTag: mocks.noop,
@@ -38,16 +90,17 @@ vi.mock('../store', () => ({
closeTabsByConnection: mocks.noop,
closeTabsByDatabase: mocks.noop,
theme: 'light',
appearance: {
enabled: true,
opacity: 1,
blur: 0,
},
appearance: mocks.state.appearance,
activeContext: mocks.state.activeContext,
tableAccessCount: {},
tableSortPreference: {},
pinnedSidebarTables: [],
recordTableAccess: mocks.noop,
setTableSortPreference: mocks.noop,
setSidebarTablePinned: mocks.noop,
addSqlLog: mocks.noop,
shortcutOptions: cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS),
setAIPanelVisible: mocks.noop,
}),
}));
@@ -80,6 +133,27 @@ vi.mock('../../wailsjs/runtime/runtime', () => ({
}));
describe('Sidebar locate toolbar', () => {
beforeEach(() => {
mocks.state.connections = [];
mocks.state.activeContext = null;
mocks.state.activeTabId = 'conn-1-main-users';
mocks.state.tabs = [{
id: 'conn-1-main-users',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
}];
mocks.state.connectionTags = [];
mocks.state.appearance = {
enabled: true,
opacity: 1,
blur: 0,
uiVersion: 'legacy',
};
});
it('resolves the table name used by the sidebar copy action', () => {
expect(resolveSidebarTableNameForCopy({
title: 'users',
@@ -91,6 +165,113 @@ describe('Sidebar locate toolbar', () => {
})).toBe('users');
});
it('treats empty lazy children as unloaded for sidebar expansion', () => {
expect(hasSidebarLazyChildren(undefined)).toBe(false);
expect(hasSidebarLazyChildren([])).toBe(false);
expect(hasSidebarLazyChildren([{ key: 'child', title: 'child' }])).toBe(true);
expect(shouldLoadSidebarNodeOnExpand({ type: 'database', children: [] })).toBe(true);
expect(shouldLoadSidebarNodeOnExpand({ type: 'database', children: [{ key: 'tables', title: '表' }] })).toBe(false);
expect(shouldLoadSidebarNodeOnExpand({ type: 'object-group', children: [] })).toBe(false);
});
it('wires tree expand and double-click expansion to lazy loading', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('if (hasSidebarLazyChildren(children)) return;');
expect(source).toContain('if (info?.expanded && shouldLoadSidebarNodeOnExpand(info.node))');
expect(source).toContain('if (shouldLoadSidebarNodeOnExpand(node))');
});
it('parses v2 command search prefixes into real search modes', () => {
expect(parseV2CommandSearchQuery('@ payment_order')).toMatchObject({
mode: 'object',
keyword: 'payment_order',
normalizedKeyword: 'payment_order',
aiPrompt: '',
});
expect(parseV2CommandSearchQuery('fs_mkefu_server_info')).toMatchObject({
mode: 'object',
keyword: 'fs_mkefu_server_info',
});
expect(parseV2CommandSearchQuery('? 帮我分析订单表')).toMatchObject({
mode: 'ai',
keyword: '帮我分析订单表',
normalizedKeyword: '帮我分析订单表',
aiPrompt: '帮我分析订单表',
});
expect(parseV2CommandSearchQuery('payment')).toMatchObject({
mode: 'default',
keyword: 'payment',
normalizedKeyword: 'payment',
});
});
it('keeps the v2 active host on the selected database connection', () => {
const connectionIds = ['local', 'dev240', 'dev241'];
const databaseNode = {
key: 'dev240-manage_admin',
dataRef: {
id: 'dev240',
dbName: 'manage_admin',
},
};
expect(resolveSidebarNodeConnectionId(databaseNode, connectionIds)).toBe('dev240');
expect(resolveV2ActiveConnectionId({
activeContextConnectionId: '',
activeTabConnectionId: 'local',
selectedKeys: [databaseNode.key],
connectionIds,
})).toBe('dev240');
});
it('keeps the v2 active host on the pinned rail connection after tree deselect', () => {
expect(resolveV2ActiveConnectionId({
activeContextConnectionId: '',
activeTabConnectionId: 'local',
selectedKeys: [],
connectionIds: ['local', 'dev240', 'dev241'],
fallbackConnectionId: 'dev240',
})).toBe('dev240');
});
it('does not clear v2 active context when rc-tree emits an empty deselect', () => {
expect(shouldClearSidebarActiveContextOnEmptySelect(true)).toBe(false);
expect(shouldClearSidebarActiveContextOnEmptySelect(false)).toBe(true);
});
it('builds v2 rail groups from existing connection tags while preserving ungrouped hosts', () => {
const connections = [
{ id: 'dev240', name: 'dev240', config: { type: 'mysql', host: '10.0.0.240' } },
{ id: 'dev241', name: 'dev241', config: { type: 'postgres', host: '10.0.0.241' } },
{ id: 'local', name: 'local', config: { type: 'mysql', host: 'localhost' } },
] as any[];
const groups = buildV2RailConnectionGroups(connections, [{
id: 'prod',
name: '生产环境',
connectionIds: ['dev241', 'missing', 'dev240'],
}]);
expect(groups.map((group) => ({
id: group.id,
name: group.name,
isUngrouped: group.isUngrouped,
connectionIds: group.connections.map((conn) => conn.id),
}))).toEqual([
{ id: 'prod', name: '生产环境', isUngrouped: undefined, connectionIds: ['dev241', 'dev240'] },
{ id: '__gonavi-v2-ungrouped-connections__', name: '未分组', isUngrouped: true, connectionIds: ['local'] },
]);
expect(getV2RailConnectionGroupBadgeText('Production')).toBe('PR');
expect(getV2RailConnectionGroupBadgeText('生产环境')).toBe('生');
});
it('keeps the sidebar memoized so parent-only button state does not repaint the tree', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('}> = React.memo(({');
});
it('renders the current table locate action in the sidebar toolbar', () => {
const markup = renderToStaticMarkup(<Sidebar />);
const externalSqlActionIndex = markup.indexOf('data-sidebar-open-external-sql-file-action="true"');
@@ -100,4 +281,424 @@ describe('Sidebar locate toolbar', () => {
expect(markup).toContain('aria-label="定位当前打开表"');
expect(locateActionIndex).toBeGreaterThan(externalSqlActionIndex);
});
it('renders the v2 sidebar rail, command search hint, filter tabs and log footer', () => {
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" sqlLogCount={2341} />);
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(markup).toContain('gn-v2-sidebar-redesign');
expect(markup).toContain('gn-v2-connection-rail');
expect(markup).toContain('gn-v2-object-explorer');
expect(markup).toContain('gn-v2-active-connection-header');
expect(markup).toContain('gn-v2-explorer-search');
expect(markup).toContain('gn-v2-explorer-command-trigger');
expect(markup).toContain('搜索表、连接、动作... 或问 AI');
expect(markup).toContain('gn-v2-search-shortcut');
expect(markup).toContain('<kbd>⌘</kbd>');
expect(markup).toContain('<kbd>K</kbd>');
expect(markup).toContain('gn-v2-explorer-filter-tabs');
expect(markup).toContain('全部');
expect(markup).toContain('视图');
expect(markup).toContain('函数');
expect(markup).toContain('aria-pressed="true"');
expect(source).toContain("const [v2ExplorerFilter, setV2ExplorerFilter] = useState<V2ExplorerFilter>('all');");
expect(source).toContain('onClick={() => setV2ExplorerFilter(item.key)}');
expect(source).toContain('treeData={isV2Ui ? v2VisibleTreeData : displayTreeData}');
expect(markup).toContain('gn-v2-sidebar-log-footer');
expect(markup).toContain('SQL 执行日志');
expect(markup).toContain('2,341');
expect(markup).toContain('gn-v2-rail-action-group');
expect(markup).toContain('data-sidebar-create-group-action="true"');
expect(markup).toContain('data-sidebar-batch-table-action="true"');
expect(markup).toContain('data-sidebar-batch-database-action="true"');
expect(markup).toContain('data-sidebar-open-external-sql-file-action="true"');
expect(markup).toContain('data-sidebar-locate-current-tab-action="true"');
expect(markup).toContain('aria-label="AI 助手"');
expect(markup).toContain('data-gonavi-ai-entry-action="true"');
expect(markup).toContain('aria-label="工具"');
expect(markup).toContain('data-gonavi-open-tools-action="true"');
expect(markup).toContain('aria-label="设置"');
expect(source).toContain('buildV2RailConnectionGroups(connections, connectionTags)');
expect(source).toContain('data-v2-rail-connection-group="true"');
expect(source).toContain('data-v2-rail-connection-group-header="true"');
expect(source).toContain("kind: 'v2-connection-group'");
expect(source).toContain('data-v2-rail-host-context-menu-trigger="true"');
expect(source).toContain('onContextMenu={(event) => openV2ConnectionContextMenu(event, conn)}');
expect(source).toContain("kind: 'v2-connection'");
expect(source).toContain("if (contextMenu.kind === 'v2-connection') return () => renderV2ConnectionContextMenu(contextMenu.node);");
const contextMenuFunction = source.slice(
source.indexOf('const openV2ConnectionContextMenu = ('),
source.indexOf('const getV2TreeMetaText = (node: any): string => {'),
);
expect(contextMenuFunction).not.toContain('setSelectedKeys');
expect(contextMenuFunction).not.toContain('selectedNodesRef.current');
expect(contextMenuFunction).not.toContain('setActiveContext');
});
it('keeps the v2 command search footer hints tied to real prefix actions', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain("const v2CommandSearchObjectMode = v2CommandSearchQuery.mode === 'object';");
expect(source).toContain("const v2CommandSearchAiMode = v2CommandSearchQuery.mode === 'ai';");
expect(source).toContain("key: 'action-ask-ai'");
expect(source).toContain("window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt'");
expect(source).toContain('<TableOutlined /> <kbd>@</kbd>只搜表对象');
expect(source).toContain('<RobotOutlined /> <kbd>?</kbd>发送给 AI');
expect(source).not.toContain('提示 · 以「@」开头按表名搜索,以「?」开头让 AI 回答');
});
it('renders v2 command action shortcuts from the shared shortcut options', () => {
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newQueryTab', activeShortcutPlatform)");
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'newConnection', activeShortcutPlatform)");
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleAIPanel', activeShortcutPlatform)");
expect(source).toContain("shortcut: resolveShortcutDisplay(shortcutOptions, 'toggleLogPanel', activeShortcutPlatform)");
expect(source).not.toContain("shortcut: '⌘N'");
expect(source).not.toContain("shortcut: '⌘⇧N'");
expect(source).not.toContain("shortcut: '⌘J'");
expect(source).not.toContain("shortcut: '⌘L'");
});
it('scales the v2 rail and footer tools from global appearance tokens', () => {
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
expect(css).toMatch(/\.gn-v2-rail-action-group,\s*body\[data-ui-version="v2"\] \.gn-v2-rail-system-actions \{[^}]*flex-direction: column;/s);
expect(css).toMatch(/\.gn-v2-rail-action-group \{[^}]*border-bottom: 0\.5px solid var\(--gn-br-1\);/s);
expect(css).toMatch(/\.gn-v2-explorer-toolbar\s*\{[^}]*display:\s*none\s*!important/s);
expect(css).toMatch(/\.ant-tree \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s);
expect(css).toMatch(/\.gn-v2-tree-title \{[^}]*font-size: var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\);/s);
expect(css).toMatch(/\.gn-v2-tree-title\.is-mono \.gn-v2-tree-label \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 1px\), 17px\);/s);
expect(css).toMatch(/\.gn-v2-tree-count \{[^}]*font-size: clamp\(9px, calc\(var\(--gn-sidebar-tree-font-size, var\(--gn-font-size-sm, 12px\)\) - 2px\), 16px\);/s);
expect(css).toMatch(/\.gn-v2-connection-rail \{[^}]*width: calc\(54px \* var\(--gn-ui-scale, 1\)\);[^}]*flex: 0 0 calc\(54px \* var\(--gn-ui-scale, 1\)\);/s);
expect(css).toMatch(/\.gn-v2-rail-items \{[^}]*padding-top: calc\(4px \* var\(--gn-ui-scale, 1\)\);/s);
expect(css).toMatch(/\.gn-v2-rail-group-header \{[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-rail-group-chevron \{[^}]*font-size: 10px;/s);
expect(css).toMatch(/\.gn-v2-rail-group-count \{[^}]*top: -1px;[^}]*right: -1px;[^}]*min-width: 16px;[^}]*height: 16px;[^}]*font-size: 9px;/s);
expect(css).toMatch(/\.gn-v2-rail-item,[^}]*\.gn-v2-rail-tool \{[^}]*width: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*height: calc\(38px \* var\(--gn-ui-scale, 1\)\);[^}]*font-size: var\(--gn-font-size-sm, 12px\);/s);
expect(css).toMatch(/\.gn-v2-rail-tool \{[^}]*height: calc\(32px \* var\(--gn-ui-scale, 1\)\);/s);
});
it('keeps v2 tree status dots circular while truncating only the label text', () => {
const css = readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8');
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
expect(source).toContain('gn-v2-tree-status is-${status}');
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title \{[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-explorer-tree-shell \.ant-tree-title > \.gn-v2-tree-title \{[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-tree-status \{[^}]*width: 14px;[^}]*height: 14px;[^}]*flex: 0 0 14px;[^}]*overflow: visible;/s);
expect(css).toMatch(/\.gn-v2-tree-status::before \{[^}]*width: 7px;[^}]*height: 7px;[^}]*border-radius: 50%;/s);
expect(css).toMatch(/\.gn-v2-tree-status\.is-success::before \{[^}]*background: #22c55e;[^}]*box-shadow: 0 0 0 4px rgba\(34, 197, 94, 0\.18\);/s);
expect(css).toMatch(/\.gn-v2-tree-label \{[^}]*overflow: hidden;[^}]*text-overflow: ellipsis;/s);
});
it('does not repeat the active connection as an object-tree root in v2', () => {
mocks.state.connections = [{
id: 'conn-local',
name: '本地',
config: {
type: 'mysql',
host: 'localhost',
port: 3306,
},
}];
mocks.state.activeContext = { connectionId: 'conn-local', dbName: '' };
mocks.state.activeTabId = '';
mocks.state.tabs = [];
mocks.state.appearance = {
enabled: true,
opacity: 1,
blur: 0,
uiVersion: 'v2',
};
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" />);
expect(markup).toContain('gn-v2-connection-rail');
expect(markup).toContain('gn-v2-active-connection-copy');
expect(markup).toContain('<strong>本地</strong>');
expect(markup).toContain('<span>localhost</span>');
expect(markup).not.toContain('gn-v2-db-icon-label');
});
it('renders existing connection tags as collapsible groups in the v2 rail', () => {
mocks.state.connections = [
{
id: 'dev240',
name: 'dev240',
config: {
type: 'mysql',
host: '10.0.0.240',
port: 3306,
},
},
{
id: 'local',
name: '本地',
config: {
type: 'postgres',
host: 'localhost',
port: 5432,
},
},
];
mocks.state.connectionTags = [{
id: 'prod',
name: '生产环境',
connectionIds: ['dev240'],
}];
mocks.state.activeContext = { connectionId: 'dev240', dbName: '' };
mocks.state.activeTabId = '';
mocks.state.tabs = [];
mocks.state.appearance = {
enabled: true,
opacity: 1,
blur: 0,
uiVersion: 'v2',
};
const markup = renderToStaticMarkup(<Sidebar uiVersion="v2" />);
expect(markup).toContain('data-v2-rail-connection-group="true"');
expect(markup).toContain('data-v2-rail-connection-group-header="true"');
expect(markup).toContain('title="生产环境 · 1 个连接"');
expect(markup).toContain('title="未分组 · 1 个连接"');
expect(markup).toContain('aria-label="折叠连接分组 生产环境"');
expect(markup).toContain('aria-label="切换到连接 dev240"');
expect(markup).toContain('aria-label="切换到连接 本地"');
expect(markup).toContain('data-v2-rail-host-context-menu-trigger="true"');
});
it('renders the v2 connection group context menu for rail group management', () => {
const markup = renderToStaticMarkup(
<V2ConnectionGroupContextMenuView
groupName="生产环境"
count={2}
/>,
);
expect(markup).toContain('data-v2-connection-group-context-menu="true"');
expect(markup).toContain('生产环境');
expect(markup).toContain('2 个连接 · 连接分组');
expect(markup).toContain('GROUP');
expect(markup).toContain('编辑分组');
expect(markup).toContain('删除分组');
});
it('filters the v2 explorer tree by object kind tabs', () => {
const tree = [{
title: 'front_end_sys',
key: 'conn-main',
type: 'database' as const,
children: [
{
title: '已存查询 · saved',
key: 'conn-main-queries',
type: 'queries-folder' as const,
children: [{ title: '日常查询', key: 'query-1', type: 'saved-query' as const }],
},
{
title: '表',
key: 'conn-main-tables',
type: 'object-group' as const,
dataRef: { groupKey: 'tables' },
children: [{ title: 'users', key: 'users', type: 'table' as const }],
},
{
title: '视图',
key: 'conn-main-views',
type: 'object-group' as const,
dataRef: { groupKey: 'views' },
children: [{ title: 'v_users', key: 'v_users', type: 'view' as const }],
},
{
title: '函数',
key: 'conn-main-routines',
type: 'object-group' as const,
dataRef: { groupKey: 'routines' },
children: [{ title: 'calc_total', key: 'calc_total', type: 'routine' as const }],
},
],
}];
expect(filterV2ExplorerTreeByKind(tree, 'all')[0].children?.map((node) => node.key)).toEqual([
'conn-main-queries',
'conn-main-tables',
'conn-main-views',
'conn-main-routines',
]);
expect(filterV2ExplorerTreeByKind(tree, 'tables')[0].children?.map((node) => node.key)).toEqual(['conn-main-tables']);
expect(filterV2ExplorerTreeByKind(tree, 'views')[0].children?.map((node) => node.key)).toEqual(['conn-main-views']);
expect(filterV2ExplorerTreeByKind(tree, 'routines')[0].children?.map((node) => node.key)).toEqual(['conn-main-routines']);
});
it('renders the v2 table context menu with the redesigned table layout', () => {
const markup = renderToStaticMarkup(
<V2TableContextMenuView
tableName="fs_mkefu_server_info"
stats={{
rowCount: 2,
dataLength: 16 * 1024,
indexLength: 16 * 1024,
engine: 'InnoDB',
}}
supportsTruncate
/>,
);
expect(markup).toContain('data-v2-table-context-menu="true"');
expect(markup).toContain('fs_mkefu_server_info');
expect(markup).toContain('InnoDB');
expect(markup).toContain('2 行 · 16 KB 数据 · 16 KB 索引');
expect(markup).toContain('查看数据');
expect(markup).toContain('↵');
expect(markup).toContain('置顶表');
expect(markup).toContain('字段 / 索引 / 外键');
expect(markup).toContain('在新标签打开');
expect(markup).toContain('⌘↵');
expect(markup).toContain('元信息');
expect(markup).toContain('查看 DDL · CREATE TABLE');
expect(markup).toContain('在 ER 图中查看');
expect(markup).toContain('复制');
expect(markup).toContain('复制表名');
expect(markup).toContain('复制表结构 · DDL');
expect(markup).toContain('复制全表为 INSERT');
expect(markup).toContain('维护');
expect(markup).toContain('重命名…');
expect(markup).toContain('备份 · SQL Dump');
expect(markup).toContain('刷新统计信息');
expect(markup).toContain('导出表数据');
expect(markup).toContain('Excel · .xlsx');
expect(markup).toContain('CSV · .csv');
expect(markup).toContain('JSON · .json');
expect(markup).not.toContain('Markdown · .md');
expect(markup).not.toContain('HTML · .html');
expect(markup).toContain('用 AI 解释这张表');
expect(markup).toContain('用 AI 生成查询');
expect(markup).toContain('截断表 · TRUNCATE');
expect(markup).toContain('删除表 · DROP');
expect(markup).not.toContain('清空表');
});
it('renders the v2 table context menu pinned state', () => {
const markup = renderToStaticMarkup(
<V2TableContextMenuView
tableName="fs_mkefu_server_info"
isPinned
/>,
);
expect(markup).toContain('取消置顶');
expect(markup).toContain('已置顶');
expect(markup).not.toContain('置顶表');
});
it('sorts pinned sidebar tables before the active sort mode', () => {
const pinnedSidebarTables = [
buildSidebarTablePinKey('conn-1', 'main', 'orders', 'public'),
];
const entries = [
{ tableName: 'users', schemaName: 'public', displayName: 'users' },
{ tableName: 'orders', schemaName: 'public', displayName: 'orders' },
{ tableName: 'audit', schemaName: 'public', displayName: 'audit' },
];
expect(isSidebarTablePinned(pinnedSidebarTables, 'conn-1', 'main', 'orders', 'public')).toBe(true);
expect(sortSidebarTableEntries(entries, {
connectionId: 'conn-1',
dbName: 'main',
sortBy: 'frequency',
tableAccessCount: {
'conn-1-main-users': 10,
'conn-1-main-orders': 1,
'conn-1-main-audit': 3,
},
pinnedSidebarTables,
}).map((entry) => entry.tableName)).toEqual(['orders', 'users', 'audit']);
});
it('formats v2 table context menu stats like the prototype header', () => {
expect(formatV2TableContextMenuRows(2)).toBe('2 行');
expect(formatV2TableContextMenuSize(16 * 1024)).toBe('16 KB');
});
it('renders the v2 database context menu with the redesigned grouped layout', () => {
const markup = renderToStaticMarkup(
<V2DatabaseContextMenuView
dbName="mkefu_ai_dev"
dialect="starrocks"
supportsStarRocksActions
/>,
);
expect(markup).toContain('data-v2-database-context-menu="true"');
expect(markup).toContain('mkefu_ai_dev');
expect(markup).toContain('DB');
expect(markup).toContain('新建表');
expect(markup).toContain('新建查询');
expect(markup).toContain('运行外部 SQL 文件');
expect(markup).toContain('StarRocks');
expect(markup).toContain('新建物化视图');
expect(markup).toContain('新建外部 Catalog');
expect(markup).toContain('维护');
expect(markup).toContain('重命名数据库');
expect(markup).toContain('刷新对象树');
expect(markup).toContain('关闭数据库');
expect(markup).toContain('导出与备份');
expect(markup).toContain('导出全部表结构 · SQL');
expect(markup).toContain('备份全部表 · 结构 + 数据');
expect(markup).toContain('删除数据库 · DROP');
});
it('renders the v2 connection context menu for host rail actions', () => {
const markup = renderToStaticMarkup(
<V2ConnectionContextMenuView
connectionName="dev240"
hostSummary="10.0.0.240:3306"
driverLabel="mysql"
tags={[
{ id: 'prod', name: '生产环境', selected: true },
{ id: 'debug', name: '临时调试' },
]}
/>,
);
expect(markup).toContain('data-v2-connection-context-menu="true"');
expect(markup).toContain('dev240');
expect(markup).toContain('mysql · 10.0.0.240:3306');
expect(markup).toContain('HOST');
expect(markup).toContain('新建数据库');
expect(markup).toContain('刷新连接');
expect(markup).toContain('新建查询');
expect(markup).toContain('运行外部 SQL 文件');
expect(markup).toContain('编辑连接');
expect(markup).toContain('复制连接');
expect(markup).toContain('断开连接');
expect(markup).toContain('分组');
expect(markup).toContain('生产环境');
expect(markup).toContain('临时调试');
expect(markup).toContain('移出分组');
expect(markup).toContain('删除连接');
});
it('renders the v2 table group menu with sort state', () => {
const markup = renderToStaticMarkup(
<V2TableGroupContextMenuView
dbName="mkefu_ai_dev"
count={15}
currentSort="frequency"
/>,
);
expect(markup).toContain('data-v2-table-group-context-menu="true"');
expect(markup).toContain('表 · tables');
expect(markup).toContain('15 张表');
expect(markup).toContain('当前按使用频率排序');
expect(markup).toContain('新建表');
expect(markup).toContain('排序');
expect(markup).toContain('按名称排序');
expect(markup).toContain('按使用频率排序');
expect(markup).toContain('当前');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { readFileSync } from 'node:fs';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it, vi } from 'vitest';
import { resolveTabHoverOpen, TabHoverInfo, stopTabHoverDragPropagation } from './TabManager';
import type { TabData } from '../types';
describe('TabManager hover info', () => {
it('memoizes the tab workbench so parent-only modal state does not repaint open tabs', () => {
const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8');
expect(source).toContain('const TabManager: React.FC = React.memo(() => {');
});
it('renders full v2 tab hover context for table tabs', () => {
const tab: TabData = {
id: 'conn-1-main-users',
title: 'users',
type: 'table',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'users',
};
const markup = renderToStaticMarkup(
<TabHoverInfo
tab={tab}
displayTitle="[开发240] 表概览"
connectionLabel="开发240"
hostSummary="192.168.1.240"
/>,
);
expect(markup).toContain('data-tab-hover-info="true"');
expect(markup).toContain('[开发240] 表概览');
expect(markup).toContain('类型');
expect(markup).toContain('表数据');
expect(markup).toContain('连接');
expect(markup).toContain('开发240');
expect(markup).toContain('Host');
expect(markup).toContain('192.168.1.240');
expect(markup).toContain('数据库');
expect(markup).toContain('main');
expect(markup).toContain('对象');
expect(markup).toContain('users');
});
it('renders db identity for redis tabs without a database name', () => {
const tab: TabData = {
id: 'redis-keys-conn-1-db2',
title: 'db2',
type: 'redis-keys',
connectionId: 'conn-1',
redisDB: 2,
};
const markup = renderToStaticMarkup(
<TabHoverInfo
tab={tab}
displayTitle="[缓存 | 10.0.0.8] db2"
connectionLabel="缓存"
hostSummary="10.0.0.8"
/>,
);
expect(markup).toContain('REDIS');
expect(markup).toContain('Redis Key');
expect(markup).toContain('未指定');
expect(markup).toContain('db2');
});
it('stops hover card pointer events from reaching tab drag listeners without blocking text selection', () => {
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
} as unknown as React.SyntheticEvent<HTMLElement>;
stopTabHoverDragPropagation(event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalledTimes(1);
});
it('keeps tab hover hidden while the tab context menu is open', () => {
expect(resolveTabHoverOpen(true, false)).toBe(true);
expect(resolveTabHoverOpen(true, true)).toBe(false);
expect(resolveTabHoverOpen(false, true)).toBe(false);
});
it('wires hover card tab-switch and drag-blocking handlers with selectable text styles', () => {
const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8');
expect(source).toContain('onPointerDown={stopTabHoverDragPropagation}');
expect(source).toContain('onPointerUp={stopTabHoverDragPropagation}');
expect(source).toContain('onPointerDownCapture={stopTabHoverDragPropagation}');
expect(source).toContain('onMouseDown={stopTabHoverDragPropagation}');
expect(source).toContain('onMouseUp={stopTabHoverDragPropagation}');
expect(source).toContain('onClick={stopTabHoverDragPropagation}');
expect(source).toContain('onClickCapture={stopTabHoverDragPropagation}');
expect(source).toContain('onTouchStart={stopTabHoverDragPropagation}');
expect(source).toContain('onTouchEnd={stopTabHoverDragPropagation}');
expect(source).toContain('setIsHoverInfoOpen(false);');
expect(source).toContain('setIsTabMenuOpen(true);');
expect(source).toContain('open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}');
expect(source).toContain('onOpenChange={handleHoverInfoOpenChange}');
expect(source).toContain('onOpenChange={handleTabMenuOpenChange}');
expect(source).toMatch(/\.gn-v2-tab-hover-card \{[^}]*cursor: text;[^}]*user-select: text;/s);
expect(source).toMatch(/\.gn-v2-tab-hover-card \* \{[^}]*user-select: text;/s);
});
});

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useRef, useState } from 'react';
import { Tabs, Dropdown } from 'antd';
import { Button, Dropdown, Tabs, Tooltip } from 'antd';
import { AppstoreOutlined, CloseOutlined, ConsoleSqlOutlined, DatabaseOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
import type { MenuProps, TabsProps } from 'antd';
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
import type { DragStartEvent, DragEndEvent } from '@dnd-kit/core';
@@ -23,34 +24,222 @@ import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
import { resolveConnectionHostSummary } from '../utils/tabDisplay';
import { resolveConnectionAccentColor } from '../utils/connectionVisual';
const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'query') return 'SQL';
if (tab.type === 'table') return 'TABLE';
if (tab.type === 'design') return 'DESIGN';
if (tab.type === 'table-overview') return 'DB';
if (tab.type.startsWith('redis')) return 'REDIS';
if (tab.type.startsWith('jvm')) return 'JVM';
if (tab.type === 'trigger') return 'TRG';
if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? 'MV' : 'VIEW';
if (tab.type === 'routine-def') return 'FUNC';
return 'TAB';
};
const getTabKindIcon = (tab: TabData): React.ReactNode => {
if (tab.type === 'query') return <ConsoleSqlOutlined />;
if (tab.type === 'table-overview') return <DatabaseOutlined />;
if (tab.type.startsWith('redis')) return <DatabaseOutlined />;
if (tab.type.startsWith('jvm')) return <AppstoreOutlined />;
return <DatabaseOutlined />;
};
const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'query') return 'SQL 查询';
if (tab.type === 'table') return '表数据';
if (tab.type === 'design') return '表设计';
if (tab.type === 'table-overview') return '表概览';
if (tab.type === 'redis-keys') return 'Redis Key';
if (tab.type === 'redis-command') return 'Redis 命令';
if (tab.type === 'redis-monitor') return 'Redis 监控';
if (tab.type === 'jvm-overview') return 'JVM 概览';
if (tab.type === 'jvm-resource') return 'JVM 资源';
if (tab.type === 'jvm-audit') return 'JVM 审计';
if (tab.type === 'jvm-diagnostic') return 'JVM 诊断';
if (tab.type === 'jvm-monitoring') return 'JVM 监控';
if (tab.type === 'trigger') return '触发器';
if (tab.type === 'view-def') return tab.viewKind === 'materialized' ? '物化视图' : '视图';
if (tab.type === 'routine-def') return '函数 / 过程';
return '标签页';
};
const getTabObjectLabel = (tab: TabData): string => {
if (tab.tableName) return tab.tableName;
if (tab.viewName) return tab.viewName;
if (tab.routineName) return tab.routineName;
if (tab.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return tab.filePath;
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
return '';
};
export const stopTabHoverDragPropagation = (event: React.SyntheticEvent<HTMLElement>) => {
event.stopPropagation();
};
export const resolveTabHoverOpen = (isHoverInfoOpen: boolean, isTabMenuOpen: boolean) =>
isHoverInfoOpen && !isTabMenuOpen;
type TabHoverInfoProps = {
tab: TabData;
displayTitle: string;
connectionLabel?: string;
hostSummary?: string;
};
export const TabHoverInfo: React.FC<TabHoverInfoProps> = ({
tab,
displayTitle,
connectionLabel,
hostSummary,
}) => {
const objectLabel = getTabObjectLabel(tab);
const rows = [
['类型', getTabKindTooltipLabel(tab)],
['连接', connectionLabel || '未绑定连接'],
['Host', hostSummary || '未配置'],
['数据库', tab.dbName || '未指定'],
['对象', objectLabel],
].filter(([, value]) => Boolean(value));
return (
<div
className="gn-v2-tab-hover-card"
data-tab-hover-info="true"
onPointerDown={stopTabHoverDragPropagation}
onPointerMove={stopTabHoverDragPropagation}
onPointerUp={stopTabHoverDragPropagation}
onPointerDownCapture={stopTabHoverDragPropagation}
onPointerUpCapture={stopTabHoverDragPropagation}
onMouseDown={stopTabHoverDragPropagation}
onMouseMove={stopTabHoverDragPropagation}
onMouseUp={stopTabHoverDragPropagation}
onClick={stopTabHoverDragPropagation}
onClickCapture={stopTabHoverDragPropagation}
onTouchStart={stopTabHoverDragPropagation}
onTouchMove={stopTabHoverDragPropagation}
onTouchEnd={stopTabHoverDragPropagation}
>
<div className="gn-v2-tab-hover-head">
<span>{getTabKindLabel(tab)}</span>
<strong>{displayTitle}</strong>
</div>
<div className="gn-v2-tab-hover-rows">
{rows.map(([label, value]) => (
<div className="gn-v2-tab-hover-row" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
);
};
type SortableTabLabelProps = {
tab: TabData;
displayTitle: string;
menuItems: MenuProps['items'];
accentColor?: string;
connectionLabel?: string;
hostSummary?: string;
isV2Ui?: boolean;
onClose?: () => void;
};
const SortableTabLabel: React.FC<SortableTabLabelProps> = ({
tab,
displayTitle,
menuItems,
accentColor,
connectionLabel,
hostSummary,
isV2Ui,
onClose,
}) => {
const [isHoverInfoOpen, setIsHoverInfoOpen] = useState(false);
const [isTabMenuOpen, setIsTabMenuOpen] = useState(false);
const labelStyle = accentColor
? ({ '--connection-accent': accentColor } as React.CSSProperties)
: undefined;
const handleTabLabelContextMenu = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
setIsHoverInfoOpen(false);
setIsTabMenuOpen(true);
};
const handleTabMenuOpenChange = (open: boolean) => {
setIsTabMenuOpen(open);
setIsHoverInfoOpen(false);
};
const handleHoverInfoOpenChange = (open: boolean) => {
setIsHoverInfoOpen(open && !isTabMenuOpen);
};
const labelNode = (
<span
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}${isV2Ui ? ' gn-v2-tab-label' : ''}`}
onContextMenu={handleTabLabelContextMenu}
title={isV2Ui ? undefined : displayTitle}
style={labelStyle}
>
{isV2Ui ? (
<span className="gn-v2-tab-kind-icon" aria-hidden="true">
{getTabKindIcon(tab)}
</span>
) : null}
{isV2Ui ? <span className="gn-v2-tab-kind">{getTabKindLabel(tab)}</span> : null}
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
<span className="tab-title-text">{displayTitle}</span>
{isV2Ui && connectionLabel ? <span className="gn-v2-tab-conn">{connectionLabel}</span> : null}
{isV2Ui && onClose ? (
<button
type="button"
className="gn-v2-tab-close"
aria-label={`关闭 ${displayTitle}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onClose();
}}
>
<CloseOutlined />
</button>
) : null}
</span>
);
const wrappedLabel = isV2Ui ? (
<Tooltip
title={(
<TabHoverInfo
tab={tab}
displayTitle={displayTitle}
connectionLabel={connectionLabel}
hostSummary={hostSummary}
/>
)}
placement="bottomLeft"
mouseEnterDelay={0.25}
open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}
onOpenChange={handleHoverInfoOpenChange}
destroyOnHidden
rootClassName="gn-v2-tab-hover-tooltip"
>
{labelNode}
</Tooltip>
) : labelNode;
return (
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
<span
className={`tab-dnd-label${accentColor ? ' has-connection-accent' : ''}`}
onContextMenu={(e) => e.preventDefault()}
title={displayTitle}
style={labelStyle}
>
{accentColor ? <span className="tab-connection-accent" aria-hidden="true" /> : null}
<span className="tab-title-text">{displayTitle}</span>
</span>
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} onOpenChange={handleTabMenuOpenChange}>
{wrappedLabel}
</Dropdown>
);
};
@@ -81,10 +270,11 @@ const DraggableTabNode: React.FC<DraggableTabNodeProps> = ({ node }) => {
});
};
const TabManager: React.FC = () => {
const TabManager: React.FC = React.memo(() => {
const tabs = useStore(state => state.tabs);
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const activeTabId = useStore(state => state.activeTabId);
const setActiveTab = useStore(state => state.setActiveTab);
const addTab = useStore(state => state.addTab);
@@ -94,6 +284,7 @@ const TabManager: React.FC = () => {
const closeTabsToRight = useStore(state => state.closeTabsToRight);
const closeAllTabs = useStore(state => state.closeAllTabs);
const moveTab = useStore(state => state.moveTab);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const tabsNavBorderColor = theme === 'dark' ? 'rgba(255, 255, 255, 0.09)' : 'rgba(0, 0, 0, 0.08)';
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const suppressClickUntilRef = useRef<number>(0);
@@ -102,6 +293,8 @@ const TabManager: React.FC = () => {
activationConstraint: { distance: 8 },
})
);
const isV2Ui = appearance.uiVersion === 'v2';
const hasTabs = tabs.length > 0;
const onChange = (newActiveKey: string) => {
setActiveTab(newActiveKey);
@@ -198,6 +391,7 @@ const TabManager: React.FC = () => {
const connection = connections.find((conn) => conn.id === tab.connectionId);
const displayTitle = buildTabDisplayTitle(tab, connection);
const accentColor = connection ? resolveConnectionAccentColor(connection) : undefined;
const hostSummary = resolveConnectionHostSummary(connection?.config);
const tabIsActive = tab.id === activeTabId;
let content;
if (tab.type === 'query') {
@@ -261,18 +455,84 @@ const TabManager: React.FC = () => {
return {
label: (
<SortableTabLabel
tab={tab}
displayTitle={displayTitle}
menuItems={menuItems}
accentColor={accentColor}
connectionLabel={connection?.name}
hostSummary={hostSummary}
isV2Ui={isV2Ui}
onClose={() => closeTab(tab.id)}
/>
),
key: tab.id,
closable: !isV2Ui,
children: content,
};
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs]);
}), [tabs, connections, activeTabId, closeOtherTabs, closeTabsToLeft, closeTabsToRight, closeAllTabs, closeTab, isV2Ui]);
const handleOpenConnectionModal = () => {
const target = document.querySelector<HTMLButtonElement>('[data-gonavi-create-connection-action="true"]');
target?.click();
};
const handleOpenAI = () => {
setAIPanelVisible(true);
};
const EmptyWorkbench = (
<div className="gn-v2-empty-workbench">
<section className="gn-v2-empty-hero" aria-label="GoNavi 起始工作台">
<div className="gn-v2-empty-eyebrow">
<span>WORKBENCH</span>
<span>{connections.length} connections</span>
</div>
<h1></h1>
<p> AI </p>
<div className="gn-v2-empty-actions">
<Button type="primary" icon={<PlusOutlined />} onClick={handleOpenConnectionModal}>
</Button>
<Button icon={<ConsoleSqlOutlined />} onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
</Button>
<Button icon={<RobotOutlined />} onClick={handleOpenAI}>
AI
</Button>
</div>
</section>
<section className="gn-v2-empty-panel" aria-label="快捷工作流">
<div className="gn-v2-panel-heading">
<span></span>
<AppstoreOutlined />
</div>
<button type="button" onClick={handleOpenConnectionModal}>
<DatabaseOutlined />
<span>
<strong></strong>
<small>URISSH</small>
</span>
</button>
<button type="button" onClick={() => window.dispatchEvent(new CustomEvent('gonavi:create-query-tab'))}>
<ConsoleSqlOutlined />
<span>
<strong> SQL </strong>
<small></small>
</span>
</button>
<button type="button" onClick={handleOpenAI}>
<RobotOutlined />
<span>
<strong> AI </strong>
<small> SQL</small>
</span>
</button>
</section>
</div>
);
return (
<>
<div className={isV2Ui ? 'gn-v2-tab-workbench' : undefined}>
<style>{`
.main-tabs {
height: 100%;
@@ -369,11 +629,87 @@ const TabManager: React.FC = () => {
background: rgba(24, 144, 255, 0.10) !important;
border-color: rgba(24, 144, 255, 0.28) !important;
}
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
body[data-theme='dark'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: rgba(255, 214, 102, 0.12) !important;
border-color: rgba(255, 214, 102, 0.4) !important;
}
body[data-ui-version='v2'] .main-tabs .ant-tabs-tab.ant-tabs-tab-active {
background: var(--gn-bg-panel) !important;
border-color: var(--gn-br-2) !important;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip .ant-tooltip-inner {
min-width: 260px;
padding: 0;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-tooltip {
pointer-events: auto;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
color: var(--gn-fg-2);
cursor: text;
user-select: text;
-webkit-user-select: text;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-card * {
user-select: text;
-webkit-user-select: text;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head > span {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 5px;
background: var(--gn-bg-active);
color: var(--gn-accent-2);
font-family: var(--gn-font-mono);
font-size: 10px;
font-weight: 700;
line-height: 14px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-head > strong {
min-width: 0;
overflow: hidden;
color: var(--gn-fg-1);
font-size: var(--gn-font-size-sm, 12px);
font-weight: 700;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-rows {
display: grid;
gap: 5px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row {
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
align-items: start;
gap: 8px;
font-size: var(--gn-font-size-sm, 12px);
line-height: 18px;
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row > span {
color: var(--gn-fg-5);
}
body[data-ui-version='v2'] .gn-v2-tab-hover-row > strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--gn-fg-2);
font-weight: 600;
}
`}</style>
{isV2Ui && !hasTabs ? (
EmptyWorkbench
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -384,9 +720,9 @@ const TabManager: React.FC = () => {
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
<Tabs
className="main-tabs"
className={`main-tabs${isV2Ui ? ' gn-v2-main-tabs' : ''}`}
type="editable-card"
destroyInactiveTabPane={false}
destroyOnHidden={false}
onChange={(newActiveKey) => {
if (Date.now() < suppressClickUntilRef.current) return;
onChange(newActiveKey);
@@ -399,8 +735,9 @@ const TabManager: React.FC = () => {
/>
</SortableContext>
</DndContext>
</>
)}
</div>
);
};
});
export default TabManager;

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useContext, useMemo, useRef, useCallback } from 'react';
import { Table, Tabs, Button, message, Input, Checkbox, Modal, AutoComplete, Tooltip, Select, Empty, Space, Tag, Radio } from 'antd';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined } from '@ant-design/icons';
import { ReloadOutlined, SaveOutlined, PlusOutlined, DeleteOutlined, MenuOutlined, FileTextOutlined, EyeOutlined, EditOutlined, ExclamationCircleOutlined, CopyOutlined, TableOutlined } from '@ant-design/icons';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
@@ -428,9 +428,14 @@ const TableDesigner: React.FC<{ tab: TabData }> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const resizeGuideColor = darkMode ? '#f6c453' : '#1890ff';
const readOnly = !!tab.readOnly;
const designerTableTitle = tab.tableName || newTableName || '未命名表';
const designerDbTitle = tab.dbName || '默认库';
const designerColumnSummary = `${columns.length} 字段`;
const panelRadius = 10;
const panelFrameColor = darkMode ? 'rgba(0, 0, 0, 0.18)' : 'rgba(0, 0, 0, 0.12)';
const panelToolbarBorder = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
@@ -2495,7 +2500,7 @@ END;`;
const columnsTabContent = (
<div
ref={containerRef}
className="table-designer-wrapper"
className={`table-designer-wrapper${isV2Ui ? ' gn-v2-designer-table-shell' : ''}`}
style={{
height: '100%',
overflow: 'hidden',
@@ -2553,7 +2558,7 @@ END;`;
);
return (
<div ref={shellRef} className="table-designer-shell" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0', position: 'relative' }}>
<div ref={shellRef} className={`table-designer-shell${isV2Ui ? ' gn-v2-table-designer' : ''}`} style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0, padding: '6px 0', position: 'relative' }}>
<style>{`
.table-designer-shell .ant-table,
.table-designer-shell .ant-table-wrapper,
@@ -2644,7 +2649,21 @@ END;`;
willChange: 'transform',
}}
/>
{isV2Ui && (
<div className="gn-v2-designer-header">
<div className="gn-v2-designer-title">
<span>SCHEMA DESIGNER</span>
<strong>{designerTableTitle}</strong>
</div>
<div className="gn-v2-designer-meta">
<span><TableOutlined /> {designerDbTitle}</span>
<span>{designerColumnSummary}</span>
{readOnly && <span></span>}
</div>
</div>
)}
<div
className={isV2Ui ? 'gn-v2-designer-toolbar' : undefined}
style={{
padding: '10px 12px 8px 12px',
borderBottom: `1px solid ${panelToolbarBorder}`,
@@ -2716,6 +2735,7 @@ END;`;
<div style={{ flex: 1 }} />
</div>
<Tabs
className={isV2Ui ? 'gn-v2-designer-tabs' : undefined}
activeKey={activeKey}
onChange={(key) => React.startTransition(() => setActiveKey(key))}
style={{
@@ -2747,9 +2767,9 @@ END;`;
key: 'indexes',
label: '索引',
children: (
<div className="index-table-wrap" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className={`index-table-wrap${isV2Ui ? ' gn-v2-designer-tab-content' : ''}`} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsIndexSchemaOps()} onClick={openCreateIndexModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length !== 1} onClick={openEditIndexModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsIndexSchemaOps() || selectedIndexKeys.length === 0} onClick={handleDeleteIndex}></Button>
@@ -2765,7 +2785,7 @@ END;`;
)}
</div>
)}
<div style={{ color: '#888', fontSize: 12 }}>
<div className={isV2Ui ? 'gn-v2-designer-section-note' : undefined} style={{ color: '#888', fontSize: 12 }}>
{groupedIndexes.length}{groupedIndexFieldCount}
</div>
<Table
@@ -2801,9 +2821,9 @@ END;`;
key: 'foreignKeys',
label: '外键',
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div className={isV2Ui ? 'gn-v2-designer-tab-content' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{!readOnly && (
<div style={{ display: 'flex', gap: 8 }}>
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<PlusOutlined />} disabled={!supportsForeignKeySchemaOps()} onClick={openCreateForeignKeyModal}></Button>
<Button size="small" icon={<EditOutlined />} disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={openEditForeignKeyModal}></Button>
<Button size="small" icon={<DeleteOutlined />} danger disabled={!supportsForeignKeySchemaOps() || !selectedForeignKey} onClick={handleDeleteForeignKey}></Button>
@@ -2865,8 +2885,8 @@ END;`;
key: 'triggers',
label: '触发器',
children: (
<div>
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<div className={isV2Ui ? 'gn-v2-designer-tab-content' : undefined}>
<div className={isV2Ui ? 'gn-v2-designer-actionbar' : undefined} style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<Button
size="small"
icon={<EyeOutlined />}
@@ -2929,7 +2949,7 @@ END;`;
label: 'DDL',
icon: <FileTextOutlined />,
children: (
<div style={{ height: 'calc(100vh - 200px)', border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
<div className={isV2Ui ? 'gn-v2-designer-ddl-shell' : undefined} style={{ height: 'calc(100vh - 200px)', border: `1px solid ${panelFrameColor}`, borderRadius: panelRadius, background: panelBodyBg }}>
<Editor
height="100%"
language="sql"

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback, useDeferredValue } from 'react';
import { Input, Spin, Empty, Dropdown, message, Tooltip, Modal, Button } from 'antd';
import type { MenuProps } from 'antd';
import { TableOutlined, SearchOutlined, ReloadOutlined, SortAscendingOutlined, DatabaseOutlined, ConsoleSqlOutlined, EditOutlined, CopyOutlined, SaveOutlined, DeleteOutlined, ExportOutlined, AppstoreOutlined, UnorderedListOutlined, WarningOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { DBQuery, DBShowCreateTable, ExportTable, DropTable, RenameTable } from '../../wailsjs/go/app/App';
@@ -18,6 +19,7 @@ import {
type TableOverviewSortOrder,
} from '../utils/tableOverviewFilter';
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
interface TableOverviewProps {
tab: TabData;
@@ -170,16 +172,21 @@ const parseTableStats = (dialect: string, rows: Record<string, any>[]): TableSta
const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
const connections = useStore(state => state.connections);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const setAIPanelVisible = useStore(state => state.setAIPanelVisible);
const addAIContext = useStore(state => state.addAIContext);
const darkMode = theme === 'dark';
const isV2Ui = appearance.uiVersion === 'v2';
const [tables, setTables] = useState<TableStatRow[]>([]);
const [loading, setLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [viewMode, setViewMode] = useState<ViewMode>(isV2Ui ? 'card' : 'list');
const [openContextMenuTable, setOpenContextMenuTable] = useState<string | null>(null);
const [visibleTableLimit, setVisibleTableLimit] = useState(TABLE_OVERVIEW_RENDER_BATCH_SIZE);
const deferredSearchText = useDeferredValue(searchText);
const isSearchPending = searchText !== deferredSearchText;
@@ -268,6 +275,49 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const openTableDdl = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `design-${connection.id}-${tab.dbName}-${tableName}`,
title: `表结构 (${tableName})`,
type: 'design',
connectionId: connection.id,
dbName: tab.dbName,
tableName,
initialTab: 'ddl',
readOnly: true,
});
}, [connection, tab.dbName, addTab, setActiveContext]);
const openQueryForTable = useCallback((tableName: string) => {
if (!connection) return;
setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: connection.id,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, tableName),
});
}, [addTab, connection, metadataDialect, setActiveContext, tab.dbName]);
const openTableInER = useCallback((tableName: string) => {
if (!connection) return;
openTable(tableName);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:data-grid:set-view-mode', {
detail: {
connectionId: connection.id,
dbName: tab.dbName,
tableName,
viewMode: 'er',
},
}));
}, 0);
}, [connection, openTable, tab.dbName]);
const buildConfig = useCallback(() => {
if (!connection) return null;
return {
@@ -319,6 +369,10 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
}
}, [buildConfig, tab.dbName]);
const handleCopyTableAsInsert = useCallback(async (tableName: string) => {
await handleExport(tableName, 'sql');
}, [handleExport]);
const handleDeleteTable = useCallback((tableName: string) => {
const config = buildConfig();
if (!config) return;
@@ -403,6 +457,60 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
});
}, [buildConfig, tab.dbName, loadData]);
const openCreateStarRocksRollup = useCallback((tableName: string) => {
if (!connection) return;
const safeTable = String(tableName || 'table_name').trim();
const quotedTable = safeTable.includes('`') ? safeTable : safeTable.split('.').map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
addTab({
id: `query-create-starrocks-rollup-${Date.now()}`,
title: '新增 Rollup',
type: 'query',
connectionId: connection.id,
dbName: tab.dbName,
query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`,
});
}, [addTab, connection, tab.dbName]);
const injectTablePromptToAI = useCallback(async (tableName: string, promptKind: 'explain' | 'query') => {
const dbName = tab.dbName || '';
if (!connection?.id || !dbName || !tableName) {
message.warning('当前表缺少连接上下文,无法发送给 AI');
return;
}
let ddl = '';
const config = buildConfig();
if (config) {
try {
const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, dbName, tableName);
if (res.success) {
ddl = String(res.data || '').trim();
addAIContext(connection.id, { dbName, tableName, ddl });
}
} catch {
// AI 入口仍可基于表名工作DDL 获取失败不阻断打开面板。
}
}
const prompt = promptKind === 'explain'
? [
`请解释数据表 ${dbName}.${tableName} 的结构和业务含义。`,
'重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。',
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n')
: [
`请基于数据表 ${dbName}.${tableName} 生成 3 条常用查询 SQL。`,
'要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。',
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n');
const wasClosed = !useStore.getState().aiPanelVisible;
if (wasClosed) setAIPanelVisible(true);
setTimeout(() => {
window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } }));
}, wasClosed ? 350 : 0);
}, [addAIContext, buildConfig, connection?.id, setAIPanelVisible, tab.dbName]);
// --- Theme ---
const cardBg = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)';
const cardHoverBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)';
@@ -435,22 +543,157 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
}, 0), [sortedFiltered]);
const allowTruncate = supportsTableTruncateAction(connection?.config?.type || '', connection?.config?.driver);
const handleV2TableContextMenuAction = useCallback((table: TableStatRow, action: V2TableContextMenuActionKey) => {
const tableName = table.name;
switch (action) {
case 'open-data':
case 'open-new-tab':
openTable(tableName);
return;
case 'design-table':
openDesign(tableName);
return;
case 'new-query':
openQueryForTable(tableName);
return;
case 'view-ddl':
openTableDdl(tableName);
return;
case 'view-er':
openTableInER(tableName);
return;
case 'copy-table-name':
void handleCopyTableName(tableName);
return;
case 'copy-structure':
void handleCopyStructure(tableName);
return;
case 'copy-insert':
void handleCopyTableAsInsert(tableName);
return;
case 'rename-table':
handleRenameTable(tableName);
return;
case 'new-rollup':
openCreateStarRocksRollup(tableName);
return;
case 'backup-table':
void handleExport(tableName, 'sql');
return;
case 'refresh-stats':
void loadData();
return;
case 'export-xlsx':
void handleExport(tableName, 'xlsx');
return;
case 'export-csv':
void handleExport(tableName, 'csv');
return;
case 'export-json':
void handleExport(tableName, 'json');
return;
case 'ai-explain':
void injectTablePromptToAI(tableName, 'explain');
return;
case 'ai-generate-query':
void injectTablePromptToAI(tableName, 'query');
return;
case 'truncate-table':
void handleTableDataDangerAction(tableName, 'truncate');
return;
case 'drop-table':
handleDeleteTable(tableName);
return;
default:
return;
}
}, [
handleCopyStructure,
handleCopyTableAsInsert,
handleCopyTableName,
handleDeleteTable,
handleExport,
handleRenameTable,
handleTableDataDangerAction,
injectTablePromptToAI,
loadData,
openCreateStarRocksRollup,
openDesign,
openQueryForTable,
openTable,
openTableDdl,
openTableInER,
]);
const renderV2OverviewTableContextMenu = useCallback((table: TableStatRow) => (
<V2TableContextMenuView
tableName={table.name}
stats={{
rowCount: table.rows,
dataLength: table.dataSize,
indexLength: table.indexSize,
engine: table.engine,
}}
supportsTruncate={allowTruncate}
supportsStarRocksRollup={metadataDialect === 'starrocks'}
onAction={(action) => {
setOpenContextMenuTable(null);
handleV2TableContextMenuAction(table, action);
}}
/>
), [allowTruncate, handleV2TableContextMenuAction, metadataDialect]);
const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => openQueryForTable(table.name) },
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(table.name) },
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(table.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(table.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(table.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(table.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(table.name) },
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(table.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(table.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(table.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(table.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(table.name, 'html') },
]},
], [
allowTruncate,
handleCopyStructure,
handleCopyTableName,
handleDeleteTable,
handleExport,
handleRenameTable,
handleTableDataDangerAction,
openDesign,
openQueryForTable,
]);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
<div className={isV2Ui ? 'gn-v2-table-overview gn-v2-table-overview-loading' : undefined} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', background: containerBg }}>
<Spin size="large" tip="加载表信息..." />
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
<div className={isV2Ui ? 'gn-v2-table-overview' : undefined} style={{ display: 'flex', flexDirection: 'column', height: '100%', background: containerBg, overflow: 'hidden' }}>
{/* Toolbar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<DatabaseOutlined style={{ fontSize: 16, color: accentColor }} />
<span style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span style={{ fontSize: 12, color: textMuted }}>
{tables.length} · {formatRows(totalRows)} · {formatSize(totalSize)}
<div className={isV2Ui ? 'gn-v2-table-overview-header' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px', flexShrink: 0 }}>
<span className={isV2Ui ? 'gn-v2-table-overview-icon' : undefined}>
<DatabaseOutlined style={{ fontSize: 16, color: isV2Ui ? undefined : accentColor }} />
</span>
<span className={isV2Ui ? 'gn-v2-table-overview-title' : undefined} style={{ fontSize: 14, fontWeight: 600, color: textPrimary }}>{tab.dbName}</span>
<span className={isV2Ui ? 'gn-v2-table-overview-summary' : undefined} style={{ fontSize: 12, color: textMuted }}>
<strong>{tables.length}</strong> · <strong>{formatRows(totalRows)}</strong> · <strong>{formatSize(totalSize)}</strong>
</span>
<div style={{ flex: 1 }} />
<Input
@@ -498,7 +741,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</div>
{/* Content Area */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
<div className={isV2Ui ? 'gn-v2-table-overview-content' : undefined} style={{ flex: 1, overflow: 'auto', padding: '0 16px 16px 16px' }}>
{sortedFiltered.length > 0 && (isSearchPending || visibleOverview.hiddenCount > 0 || deferredSearchText.trim()) && (
<div
style={{
@@ -528,7 +771,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<Empty description={searchText ? '无匹配结果' : '暂无表'} style={{ marginTop: 80 }} />
) : viewMode === 'card' ? (
/* ========== 卡片视图 ========== */
<div style={{
<div className={isV2Ui ? 'gn-v2-table-card-grid' : undefined} style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 12,
@@ -537,42 +780,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
open={isV2Ui ? openContextMenuTable === t.name : undefined}
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
>
<div
className={isV2Ui ? 'gn-v2-table-card' : undefined}
onDoubleClick={() => openTable(t.name)}
style={{
background: cardBg,
@@ -580,13 +796,13 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
borderRadius: 10,
padding: '14px 16px',
cursor: 'pointer',
transition: 'all 0.15s ease',
transition: isV2Ui ? undefined : 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div className={isV2Ui ? 'gn-v2-table-card-name' : undefined} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<TableOutlined style={{ fontSize: 14, color: accentColor }} />
<Tooltip title={t.name} mouseEnterDelay={0.4}>
<span style={{ fontSize: 13, fontWeight: 600, color: textPrimary, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1, display: 'block' }}>
@@ -601,18 +817,23 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
</div>
</Tooltip>
)}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<div className={isV2Ui ? 'gn-v2-table-card-meta' : undefined} style={{ display: 'flex', gap: 16, fontSize: 12, color: textMuted }}>
<span title="行数" style={{ minWidth: 52 }}>📊 {formatRows(t.rows)}</span>
<span title="数据大小" style={{ minWidth: 72 }}>💾 {formatSize(t.dataSize)}</span>
{t.engine && <span title="引擎" style={{ marginLeft: 'auto', opacity: 0.7 }}>{t.engine}</span>}
</div>
{isV2Ui && (
<div className="gn-v2-table-size-bar">
<span style={{ width: `${Math.min(100, Math.max(4, maxCombinedSize > 0 ? Math.round(((t.dataSize + t.indexSize) / maxCombinedSize) * 100) : 4))}%` }} />
</div>
)}
</div>
</Dropdown>
))}
</div>
) : (
/* ========== 行视图 ========== */
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className={isV2Ui ? 'gn-v2-table-row-list' : undefined} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{visibleTables.map(t => {
const combinedSize = t.dataSize + t.indexSize;
const sizeRatio = maxCombinedSize > 0 ? combinedSize / maxCombinedSize : 0;
@@ -624,42 +845,15 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
<Dropdown
key={t.name}
trigger={['contextMenu']}
menu={{
items: [
{ key: 'new-query', label: '新建查询', icon: <ConsoleSqlOutlined />, onClick: () => {
setActiveContext({ connectionId: tab.connectionId, dbName: tab.dbName || '' });
addTab({
id: `query-${Date.now()}`,
title: '新建查询',
type: 'query',
connectionId: tab.connectionId,
dbName: tab.dbName,
query: buildTableSelectQuery(metadataDialect, t.name),
});
}},
{ type: 'divider' },
{ key: 'design-table', label: '设计表', icon: <EditOutlined />, onClick: () => openDesign(t.name) },
{ key: 'copy-table-name', label: '复制表名', icon: <CopyOutlined />, onClick: () => handleCopyTableName(t.name) },
{ key: 'copy-structure', label: '复制表结构', icon: <CopyOutlined />, onClick: () => handleCopyStructure(t.name) },
{ key: 'backup-table', label: '备份表 (SQL)', icon: <SaveOutlined />, onClick: () => handleExport(t.name, 'sql') },
{ key: 'rename-table', label: '重命名表', icon: <EditOutlined />, onClick: () => handleRenameTable(t.name) },
{ key: 'danger-zone', label: '危险操作', icon: <WarningOutlined />, children: [
...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'truncate') }] : []),
{ key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(t.name, 'clear') },
{ key: 'drop-table', label: '删除表', icon: <DeleteOutlined />, danger: true, onClick: () => handleDeleteTable(t.name) }
]},
{ type: 'divider' },
{ key: 'export', label: '导出表数据', icon: <ExportOutlined />, children: [
{ key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(t.name, 'csv') },
{ key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(t.name, 'xlsx') },
{ key: 'export-json', label: '导出 JSON', onClick: () => handleExport(t.name, 'json') },
{ key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(t.name, 'md') },
{ key: 'export-html', label: '导出 HTML', onClick: () => handleExport(t.name, 'html') },
]},
],
}}
menu={{ items: isV2Ui ? [] : buildLegacyTableContextMenuItems(t) }}
open={isV2Ui ? openContextMenuTable === t.name : undefined}
onOpenChange={isV2Ui ? (open) => setOpenContextMenuTable(open ? t.name : null) : undefined}
popupRender={isV2Ui ? () => renderV2OverviewTableContextMenu(t) : undefined}
rootClassName={isV2Ui ? 'gn-v2-table-context-menu-popup' : undefined}
overlayStyle={isV2Ui ? { width: 264, maxWidth: 'calc(100vw - 24px)' } : undefined}
>
<div
className={isV2Ui ? 'gn-v2-table-row' : undefined}
onDoubleClick={() => openTable(t.name)}
style={{
position: 'relative',
@@ -668,11 +862,11 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
border: `1px solid ${cardBorder}`,
background: cardBg,
cursor: 'pointer',
transition: 'all 0.15s ease',
transition: isV2Ui ? undefined : 'all 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
onMouseEnter={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardHoverBg; (e.currentTarget as HTMLDivElement).style.borderColor = accentColor; }}
onMouseLeave={isV2Ui ? undefined : e => { (e.currentTarget as HTMLDivElement).style.background = cardBg; (e.currentTarget as HTMLDivElement).style.borderColor = cardBorder; }}
>
<div
style={{

View File

@@ -0,0 +1,606 @@
import React from 'react';
import {
CodeOutlined,
ConsoleSqlOutlined,
CopyOutlined,
DeleteOutlined,
DisconnectOutlined,
EditOutlined,
ExportOutlined,
FileAddOutlined,
FolderOpenOutlined,
FolderOutlined,
SaveOutlined,
LinkOutlined,
ReloadOutlined,
TableOutlined,
ThunderboltOutlined,
DatabaseOutlined,
CheckSquareOutlined,
CloudOutlined,
ClearOutlined,
DashboardOutlined,
FileTextOutlined,
HddOutlined,
PushpinOutlined,
VerticalAlignBottomOutlined,
} from '@ant-design/icons';
export type V2TableContextMenuActionKey =
| 'pin-table'
| 'unpin-table'
| 'open-data'
| 'design-table'
| 'open-new-tab'
| 'new-query'
| 'view-ddl'
| 'view-er'
| 'copy-table-name'
| 'copy-structure'
| 'copy-insert'
| 'rename-table'
| 'new-rollup'
| 'backup-table'
| 'refresh-stats'
| 'export-xlsx'
| 'export-csv'
| 'export-json'
| 'ai-explain'
| 'ai-generate-query'
| 'truncate-table'
| 'drop-table';
export type V2TableContextMenuStats = {
rowCount?: number;
dataLength?: number;
indexLength?: number;
engine?: string;
loading?: boolean;
unavailable?: boolean;
};
type V2TableContextMenuItemConfig = {
action: string;
icon: React.ReactNode;
title: string;
kbd?: string;
featured?: boolean;
selected?: boolean;
disabled?: boolean;
tone?: 'default' | 'ai' | 'danger';
};
export const formatV2TableContextMenuRows = (count?: number): string => {
if (count === undefined || count === null || !Number.isFinite(count) || count < 0) return '— 行';
return `${Math.round(count).toLocaleString()}`;
};
export const formatV2TableContextMenuSize = (bytes?: number): string => {
if (bytes === undefined || bytes === null || !Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes < 1024) return `${Math.round(bytes)} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const resolveV2TableContextMenuMeta = (stats?: V2TableContextMenuStats): string => {
if (!stats) return '点击刷新统计信息读取';
if (stats?.loading) return '正在读取统计信息…';
if (stats?.unavailable) return '统计信息不可用';
return `${formatV2TableContextMenuRows(stats?.rowCount)} · ${formatV2TableContextMenuSize(stats?.dataLength)} 数据 · ${formatV2TableContextMenuSize(stats?.indexLength)} 索引`;
};
const V2TableContextMenuItem: React.FC<{
item: V2TableContextMenuItemConfig;
onAction?: (action: string) => void;
}> = ({ item, onAction }) => (
<button
type="button"
className={[
'gn-v2-context-menu-item',
item.featured ? 'is-featured' : '',
item.selected ? 'is-selected' : '',
item.tone === 'ai' ? 'is-ai' : '',
item.tone === 'danger' ? 'is-danger' : '',
item.tone === 'default' ? 'is-default' : '',
item.disabled ? 'is-disabled' : '',
].filter(Boolean).join(' ')}
role="menuitem"
disabled={item.disabled}
aria-disabled={item.disabled || undefined}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (item.disabled) return;
onAction?.(item.action);
}}
>
<span className="gn-v2-context-menu-item-icon">{item.icon}</span>
<span className="gn-v2-context-menu-item-title">{item.title}</span>
{item.kbd && <span className="gn-v2-context-menu-kbd">{item.kbd}</span>}
</button>
);
const V2ContextMenuHeader: React.FC<{
icon: React.ReactNode;
title: string;
meta: string;
pill?: string;
}> = ({ icon, title, meta, pill }) => (
<div className="gn-v2-context-menu-header">
<span className="gn-v2-context-menu-table-icon">{icon}</span>
<span className="gn-v2-context-menu-heading">
<strong title={title}>{title}</strong>
<small>{meta}</small>
</span>
{pill && (
<span className="gn-v2-context-menu-engine-pill">{pill}</span>
)}
</div>
);
const renderV2ContextMenuItems = (
items: V2TableContextMenuItemConfig[],
onAction?: (action: string) => void,
) => items.map((item) => (
<V2TableContextMenuItem key={item.action} item={item} onAction={onAction} />
));
export const V2TableContextMenuView: React.FC<{
tableName: string;
stats?: V2TableContextMenuStats;
isPinned?: boolean;
supportsTruncate?: boolean;
supportsStarRocksRollup?: boolean;
onAction?: (action: V2TableContextMenuActionKey) => void;
}> = ({
tableName,
stats,
isPinned = false,
supportsTruncate = true,
supportsStarRocksRollup = false,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
const maintenanceItems: V2TableContextMenuItemConfig[] = [
{ action: 'rename-table', icon: <EditOutlined />, title: '重命名…', kbd: 'F2' },
...(supportsStarRocksRollup ? [{ action: 'new-rollup' as const, icon: <ThunderboltOutlined />, title: '新增 Rollup' }] : []),
{ action: 'backup-table', icon: <ExportOutlined />, title: '备份 · SQL Dump' },
{ action: 'refresh-stats', icon: <ReloadOutlined />, title: '刷新统计信息' },
];
const dangerItems: V2TableContextMenuItemConfig[] = [
...(supportsTruncate ? [{ action: 'truncate-table' as const, icon: <DeleteOutlined />, title: '截断表 · TRUNCATE', tone: 'danger' as const }] : []),
{ action: 'drop-table', icon: <DeleteOutlined />, title: '删除表 · DROP', kbd: '⌫', tone: 'danger' },
];
return (
<div className="gn-v2-table-context-menu" data-v2-table-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<TableOutlined />}
title={tableName}
meta={resolveV2TableContextMenuMeta(stats)}
pill={(stats?.engine || stats?.loading) ? (stats?.loading ? '...' : stats?.engine) : undefined}
/>
<div className="gn-v2-context-menu-body">
{renderItems([
{ action: 'open-data', icon: <TableOutlined />, title: '查看数据', kbd: '↵', featured: true },
{ action: isPinned ? 'unpin-table' : 'pin-table', icon: <PushpinOutlined />, title: isPinned ? '取消置顶' : '置顶表', kbd: isPinned ? '已置顶' : undefined, selected: isPinned },
{ action: 'design-table', icon: <EditOutlined />, title: '设计表 · 字段 / 索引 / 外键', kbd: '⌘D' },
{ action: 'open-new-tab', icon: <FileAddOutlined />, title: '在新标签打开', kbd: '⌘↵' },
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'view-ddl', icon: <CodeOutlined />, title: '查看 DDL · CREATE TABLE' },
{ action: 'view-er', icon: <LinkOutlined />, title: '在 ER 图中查看' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'copy-table-name', icon: <CopyOutlined />, title: '复制表名', kbd: '⌘C' },
{ action: 'copy-structure', icon: <CopyOutlined />, title: '复制表结构 · DDL' },
{ action: 'copy-insert', icon: <CopyOutlined />, title: '复制全表为 INSERT' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems(maintenanceItems)}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'export-xlsx', icon: <ExportOutlined />, title: 'Excel · .xlsx' },
{ action: 'export-csv', icon: <ExportOutlined />, title: 'CSV · .csv' },
{ action: 'export-json', icon: <ExportOutlined />, title: 'JSON · .json' },
])}
<div className="gn-v2-context-menu-divider" />
{renderItems([
{ action: 'ai-explain', icon: <ThunderboltOutlined />, title: '用 AI 解释这张表', tone: 'ai', featured: true },
{ action: 'ai-generate-query', icon: <ConsoleSqlOutlined />, title: '用 AI 生成查询', tone: 'ai' },
])}
<div className="gn-v2-context-menu-divider" />
{renderItems(dangerItems)}
</div>
</div>
);
};
export type V2TableGroupContextMenuActionKey =
| 'new-table'
| 'sort-by-name'
| 'sort-by-frequency';
export const V2TableGroupContextMenuView: React.FC<{
title?: string;
dbName?: string;
count?: number;
currentSort?: 'name' | 'frequency';
onAction?: (action: V2TableGroupContextMenuActionKey) => void;
}> = ({
title = '表 · tables',
dbName,
count,
currentSort = 'name',
onAction,
}) => {
const sortLabel = currentSort === 'frequency' ? '使用频率' : '名称';
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
return (
<div className="gn-v2-table-context-menu gn-v2-group-context-menu" data-v2-table-group-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<TableOutlined />}
title={title}
meta={`${dbName || '当前数据库'} · ${count ?? 0} 张表 · 当前按${sortLabel}排序`}
pill="GROUP"
/>
<div className="gn-v2-context-menu-body">
{renderItems([
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: '⌘N', featured: true },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'sort-by-name', icon: currentSort === 'name' ? <CheckSquareOutlined /> : <ReloadOutlined />, title: '按名称排序', kbd: currentSort === 'name' ? '当前' : undefined, selected: currentSort === 'name' },
{ action: 'sort-by-frequency', icon: currentSort === 'frequency' ? <CheckSquareOutlined /> : <ReloadOutlined />, title: '按使用频率排序', kbd: currentSort === 'frequency' ? '当前' : undefined, selected: currentSort === 'frequency' },
])}
</div>
</div>
);
};
export type V2DatabaseContextMenuActionKey =
| 'new-table'
| 'new-materialized-view'
| 'new-external-catalog'
| 'rename-db'
| 'refresh'
| 'export-db-schema'
| 'backup-db-sql'
| 'disconnect-db'
| 'new-query'
| 'run-sql'
| 'drop-db';
export const V2DatabaseContextMenuView: React.FC<{
dbName: string;
dialect?: string;
supportsStarRocksActions?: boolean;
onAction?: (action: V2DatabaseContextMenuActionKey) => void;
}> = ({
dbName,
dialect,
supportsStarRocksActions = false,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
return (
<div className="gn-v2-table-context-menu gn-v2-database-context-menu" data-v2-database-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<DatabaseOutlined />}
title={dbName}
meta={`${dialect || 'database'} · 数据库操作`}
pill="DB"
/>
<div className="gn-v2-context-menu-body">
{renderItems([
{ action: 'new-table', icon: <TableOutlined />, title: '新建表', kbd: '⌘N', featured: true },
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
{ action: 'run-sql', icon: <FileAddOutlined />, title: '运行外部 SQL 文件' },
])}
{supportsStarRocksActions && (
<>
<div className="gn-v2-context-menu-section-title">StarRocks</div>
{renderItems([
{ action: 'new-materialized-view', icon: <ThunderboltOutlined />, title: '新建物化视图' },
{ action: 'new-external-catalog', icon: <CloudOutlined />, title: '新建外部 Catalog' },
])}
</>
)}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'rename-db', icon: <EditOutlined />, title: '重命名数据库', kbd: 'F2' },
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新对象树' },
{ action: 'disconnect-db', icon: <DisconnectOutlined />, title: '关闭数据库' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'export-db-schema', icon: <ExportOutlined />, title: '导出全部表结构 · SQL' },
{ action: 'backup-db-sql', icon: <SaveOutlined />, title: '备份全部表 · 结构 + 数据' },
])}
<div className="gn-v2-context-menu-divider" />
{renderItems([
{ action: 'drop-db', icon: <DeleteOutlined />, title: '删除数据库 · DROP', tone: 'danger', kbd: '⌫' },
])}
</div>
</div>
);
};
export type V2ConnectionContextMenuActionKey =
| 'new-db'
| 'refresh'
| 'new-query'
| 'open-sql-file'
| 'new-command'
| 'open-monitor'
| 'edit'
| 'copy-connection'
| 'disconnect'
| 'delete'
| 'move-to-ungrouped'
| `move-to-tag:${string}`;
export type V2ConnectionContextMenuTagItem = {
id: string;
name: string;
selected?: boolean;
};
export type V2ConnectionGroupContextMenuActionKey =
| 'edit-group'
| 'delete-group';
export const V2ConnectionGroupContextMenuView: React.FC<{
groupName: string;
count?: number;
onAction?: (action: V2ConnectionGroupContextMenuActionKey) => void;
}> = ({
groupName,
count = 0,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
return (
<div className="gn-v2-table-context-menu gn-v2-connection-group-context-menu" data-v2-connection-group-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<FolderOpenOutlined />}
title={groupName || '未命名分组'}
meta={`${count.toLocaleString()} 个连接 · 连接分组`}
pill="GROUP"
/>
<div className="gn-v2-context-menu-body">
{renderItems([
{ action: 'edit-group', icon: <EditOutlined />, title: '编辑分组', kbd: 'F2', featured: true },
])}
<div className="gn-v2-context-menu-divider" />
{renderItems([
{ action: 'delete-group', icon: <DeleteOutlined />, title: '删除分组', tone: 'danger', kbd: '⌫' },
])}
</div>
</div>
);
};
export const V2ConnectionContextMenuView: React.FC<{
connectionName: string;
hostSummary?: string;
driverLabel?: string;
isRedis?: boolean;
tags?: V2ConnectionContextMenuTagItem[];
onAction?: (action: V2ConnectionContextMenuActionKey) => void;
}> = ({
connectionName,
hostSummary,
driverLabel,
isRedis = false,
tags = [],
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
const hasSelectedTag = tags.some((tag) => tag.selected);
const meta = [
driverLabel || (isRedis ? 'redis' : 'database'),
hostSummary || '未配置地址',
].filter(Boolean).join(' · ');
return (
<div className="gn-v2-table-context-menu gn-v2-connection-context-menu" data-v2-connection-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={isRedis ? <HddOutlined /> : <CloudOutlined />}
title={connectionName}
meta={meta}
pill="HOST"
/>
<div className="gn-v2-context-menu-body">
{isRedis ? renderItems([
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: '⌘R', featured: true },
{ action: 'new-command', icon: <ConsoleSqlOutlined />, title: '新建命令窗口', featured: true },
{ action: 'open-monitor', icon: <DashboardOutlined />, title: 'Redis 实例监控' },
]) : renderItems([
{ action: 'new-db', icon: <DatabaseOutlined />, title: '新建数据库', kbd: '⌘N', featured: true },
{ action: 'refresh', icon: <ReloadOutlined />, title: '刷新连接', kbd: '⌘R' },
{ action: 'new-query', icon: <ConsoleSqlOutlined />, title: '新建查询' },
{ action: 'open-sql-file', icon: <FileAddOutlined />, title: '运行外部 SQL 文件' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'edit', icon: <EditOutlined />, title: '编辑连接', kbd: 'F2' },
{ action: 'copy-connection', icon: <CopyOutlined />, title: '复制连接' },
{ action: 'disconnect', icon: <DisconnectOutlined />, title: '断开连接' },
])}
{tags.length > 0 && (
<>
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
...tags.map((tag): V2TableContextMenuItemConfig => ({
action: `move-to-tag:${tag.id}`,
icon: tag.selected ? <CheckSquareOutlined /> : <FolderOutlined />,
title: tag.name,
kbd: tag.selected ? '当前' : undefined,
selected: tag.selected,
})),
{
action: 'move-to-ungrouped',
icon: hasSelectedTag ? <FolderOpenOutlined /> : <CheckSquareOutlined />,
title: '移出分组',
kbd: hasSelectedTag ? undefined : '当前',
selected: !hasSelectedTag,
},
])}
</>
)}
<div className="gn-v2-context-menu-divider" />
{renderItems([
{ action: 'delete', icon: <DeleteOutlined />, title: '删除连接', tone: 'danger', kbd: '⌫' },
])}
</div>
</div>
);
};
export type V2CellContextMenuActionKey =
| 'copy-field-name'
| 'set-null'
| 'edit-row'
| 'fill-selected'
| 'paste-copied-columns'
| 'copy-insert'
| 'copy-update'
| 'copy-delete'
| 'copy-json'
| 'copy-csv'
| 'copy-markdown'
| 'export-csv'
| 'export-xlsx'
| 'export-json'
| 'export-html';
export const V2CellContextMenuView: React.FC<{
fieldName: string;
tableName?: string;
rowLabel?: string;
selectedRowCount?: number;
canModifyData?: boolean;
canPasteCopiedColumns?: boolean;
supportsCopyInsert?: boolean;
onAction?: (action: V2CellContextMenuActionKey) => void;
}> = ({
fieldName,
tableName,
rowLabel,
selectedRowCount = 0,
canModifyData = false,
canPasteCopiedColumns = false,
supportsCopyInsert = true,
onAction,
}) => {
const renderItems = (items: V2TableContextMenuItemConfig[]) => renderV2ContextMenuItems(
items,
onAction as (action: string) => void,
);
const selectedCountLabel = Math.max(0, selectedRowCount).toLocaleString();
const menuTitle = fieldName || '未命名字段';
const meta = [tableName, rowLabel || '当前行'].filter(Boolean).join(' · ') || '当前单元格';
return (
<div className="gn-v2-table-context-menu gn-v2-cell-context-menu" data-v2-cell-context-menu="true" role="menu">
<V2ContextMenuHeader
icon={<TableOutlined />}
title={menuTitle}
meta={meta}
pill="CELL"
/>
<div className="gn-v2-context-menu-body">
{renderItems([
{ action: 'copy-field-name', icon: <CopyOutlined />, title: '复制字段名称', kbd: '⌘C', featured: true },
])}
{canModifyData && (
<>
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'set-null', icon: <ClearOutlined />, title: '设置为 NULL' },
{ action: 'edit-row', icon: <EditOutlined />, title: '编辑本行', kbd: '↵' },
{
action: 'fill-selected',
icon: <VerticalAlignBottomOutlined />,
title: `填充到选中行 (${selectedCountLabel})`,
disabled: selectedRowCount <= 0,
},
{
action: 'paste-copied-columns',
icon: <VerticalAlignBottomOutlined />,
title: '粘贴已复制列 · 同名列',
disabled: !canPasteCopiedColumns,
},
])}
</>
)}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
...(supportsCopyInsert ? [
{ action: 'copy-insert' as const, icon: <ConsoleSqlOutlined />, title: '复制为 INSERT', kbd: 'SQL' },
{ action: 'copy-update' as const, icon: <ConsoleSqlOutlined />, title: '复制为 UPDATE' },
{ action: 'copy-delete' as const, icon: <ConsoleSqlOutlined />, title: '复制为 DELETE' },
] : []),
{ action: 'copy-json', icon: <FileTextOutlined />, title: '复制为 JSON' },
{ action: 'copy-csv', icon: <FileTextOutlined />, title: '复制为 CSV' },
{ action: 'copy-markdown', icon: <CopyOutlined />, title: '复制为 Markdown' },
])}
<div className="gn-v2-context-menu-section-title"></div>
{renderItems([
{ action: 'export-csv', icon: <ExportOutlined />, title: 'CSV · .csv' },
{ action: 'export-xlsx', icon: <ExportOutlined />, title: 'Excel · .xlsx' },
{ action: 'export-json', icon: <ExportOutlined />, title: 'JSON · .json' },
{ action: 'export-html', icon: <ExportOutlined />, title: 'HTML · .html' },
])}
</div>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button, Tooltip } from 'antd';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined } from '@ant-design/icons';
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIChatMessage } from '../../types';
@@ -9,12 +9,15 @@ interface AIChatHeaderProps {
mutedColor: string;
textColor: string;
overlayTheme: OverlayWorkbenchTheme;
isV2Ui?: boolean;
onHistoryClick: () => void;
onClear: () => void;
onSettingsClick: () => void;
onClose: () => void;
messages?: AIChatMessage[];
sessionTitle?: string;
activeMode?: 'chat' | 'insights' | 'history';
onModeChange?: (mode: 'chat' | 'insights' | 'history') => void;
}
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
@@ -41,36 +44,108 @@ const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
darkMode, mutedColor, textColor, overlayTheme,
isV2Ui = false,
onHistoryClick, onClear, onSettingsClick, onClose,
messages = [], sessionTitle = '新对话'
messages = [], sessionTitle = '新对话',
activeMode = 'chat',
onModeChange,
}) => {
return (
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
<div className="ai-chat-header-left" style={{ gap: 8 }}>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
</Tooltip>
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
<RobotOutlined />
</div>
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
{messages.length > 0 && (
<Tooltip title="导出为 Markdown">
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
if (!isV2Ui) {
return (
<div className="ai-chat-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
<div className="ai-chat-header-left" style={{ gap: 8 }}>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
</Tooltip>
)}
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
<RobotOutlined />
</div>
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
</div>
<div className="ai-chat-header-right">
{messages.length > 0 && (
<Tooltip title="导出为 Markdown">
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
</Tooltip>
)}
<Tooltip title="新对话 (清空当前)">
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
</div>
);
}
return (
<div className="ai-chat-header gn-v2-ai-header" style={{ borderBottom: 'none', padding: '10px 16px', background: darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(0,0,0,0.01)' }}>
<div className="gn-v2-ai-header-top">
<div className="ai-chat-header-left gn-v2-ai-brand" style={{ gap: 8 }}>
<div className="ai-logo" style={{ background: overlayTheme.iconBg, color: overlayTheme.iconColor, display: 'flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 20, borderRadius: 6, fontSize: 12 }}>
<RobotOutlined />
</div>
<div className="ai-title-stack">
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
<small>{sessionTitle} · </small>
</div>
<span className="gn-v2-ai-provider-badge">BETA</span>
</div>
<div className="ai-chat-header-right gn-v2-ai-header-actions">
<Tooltip title="新对话">
<Button type="text" size="small" icon={<PlusOutlined />} onClick={onClear} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="历史会话">
<Button type="text" size="small" icon={<HistoryOutlined />} onClick={onHistoryClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="AI 设置">
<Button type="text" size="small" icon={<SettingOutlined />} onClick={onSettingsClick} style={{ color: mutedColor }} />
</Tooltip>
<Tooltip title="关闭面板">
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: mutedColor }} />
</Tooltip>
</div>
</div>
<div className="gn-v2-ai-mode-tabs" aria-label="AI 工作模式">
<button
type="button"
className={activeMode === 'chat' ? 'is-active' : undefined}
onClick={() => onModeChange?.('chat')}
>
<RobotOutlined />
<span></span>
</button>
<button
type="button"
className={activeMode === 'insights' ? 'is-active' : undefined}
onClick={() => onModeChange?.('insights')}
>
<ThunderboltOutlined />
<span></span>
</button>
<button
type="button"
className={activeMode === 'history' ? 'is-active' : undefined}
onClick={() => onModeChange?.('history')}
>
<HistoryOutlined />
<span></span>
</button>
</div>
{messages.length > 0 && (
<div className="gn-v2-ai-session-row">
<button type="button" className="gn-v2-ai-export-button" onClick={() => exportToMarkdown(messages, sessionTitle)}>
<ExportOutlined />
<span></span>
</button>
</div>
)}
</div>
);
};

View File

@@ -17,8 +17,38 @@ vi.mock('../../../wailsjs/go/app/App', () => ({
DBGetTables: vi.fn(),
DBShowCreateTable: vi.fn(),
DBGetDatabases: vi.fn(),
DBGetColumns: vi.fn(),
}));
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
isV2Ui
{...overrides}
/>
);
describe('AIChatInput notice layout', () => {
it('renders the composer notice above the input editor', () => {
const markup = renderToStaticMarkup(
@@ -49,6 +79,7 @@ describe('AIChatInput notice layout', () => {
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
isV2Ui
/>
);
@@ -77,6 +108,7 @@ describe('AIChatInput notice layout', () => {
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
shortcutPlatform="mac"
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
@@ -85,9 +117,63 @@ describe('AIChatInput notice layout', () => {
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
isV2Ui
/>
);
expect(markup).toContain('Meta+Enter 发送');
expect(markup).toContain('⌘↵ 发送');
});
it('renders the model selector without the filled select variant', () => {
const markup = renderToStaticMarkup(
<AIChatInput
input=""
setInput={() => {}}
draftImages={[]}
setDraftImages={() => {}}
sending={false}
onSend={() => {}}
onStop={() => {}}
handleKeyDown={() => {}}
activeConnName=""
activeContext={null}
activeProvider={{ model: 'gpt-5.5', models: ['gpt-5.5'] }}
dynamicModels={[]}
loadingModels={false}
sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={null}
onModelChange={() => {}}
onFetchModels={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
mutedColor="rgba(16,24,40,0.55)"
overlayTheme={buildOverlayWorkbenchTheme(false)}
isV2Ui
/>
);
expect(markup).toContain('gn-v2-ai-model-select');
expect(markup).not.toContain('ant-select-filled');
expect(markup).not.toContain('ant-select-show-search');
});
it('renders an enabled type-safe send button when text is present', () => {
const markup = renderAIChatInput({ input: 'select 1' });
const sendButton = markup.match(/<button[^>]*class="ai-chat-send-btn gn-v2-ai-send"[^>]*>/)?.[0] || '';
expect(sendButton).toContain('type="button"');
expect(sendButton).toContain('title="发送"');
expect(sendButton).not.toContain('disabled');
});
it('keeps the legacy composer free of v2-only layout classes by default', () => {
const markup = renderAIChatInput({ isV2Ui: false, input: 'select 1' });
expect(markup).toContain('class="ai-chat-input-area"');
expect(markup).toContain('class="ai-chat-send-btn"');
expect(markup).not.toContain('gn-v2-ai-composer');
expect(markup).not.toContain('gn-v2-ai-model-select');
expect(markup).not.toContain('gn-v2-ai-send');
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { Input, Select, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { CodeOutlined, DatabaseOutlined, DownOutlined, PlusOutlined, SendOutlined, StopOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
@@ -8,7 +8,7 @@ import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
import type { ShortcutBinding } from '../../utils/shortcuts';
import type { ShortcutPlatform, ShortcutPlatformBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
@@ -24,7 +24,8 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
sendShortcutBinding: ShortcutBinding;
sendShortcutBinding: ShortcutPlatformBinding;
shortcutPlatform?: ShortcutPlatform;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
@@ -35,14 +36,15 @@ interface AIChatInputProps {
overlayTheme: OverlayWorkbenchTheme;
contextUsageChars?: number;
maxContextChars?: number;
isV2Ui?: boolean;
}
export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
sendShortcutBinding, composerNotice,
sendShortcutBinding, shortcutPlatform = 'windows', composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
contextUsageChars, maxContextChars, isV2Ui = false
}) => {
const [contextOpen, setContextOpen] = React.useState(false);
const [contextLoading, setContextLoading] = React.useState(false);
@@ -245,10 +247,397 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
}
};
if (!isV2Ui) {
return (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 8,
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{activeContextItems.length > 0 && (
<Tag
onClick={() => setContextExpanded(!contextExpanded)}
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
)}
{contextExpanded && activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
>
<span style={{ fontSize: 13 }}>🗄 {ctx.tableName}</span>
</Tag>
))}
{draftImages.map((b64, i) => (
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
<div
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
>
</div>
</div>
))}
</div>
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: '8px 10px',
borderRadius: 12,
background: composerNoticePalette.background,
border: `1px solid ${composerNoticePalette.borderColor}`,
}}
>
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
{composerNotice.title}
</div>
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
{composerNotice.description}
</div>
</div>
</div>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
maxHeight: 220, overflowY: 'auto', padding: 4
}}>
{filteredSlashCmds.map(cmd => (
<div
key={cmd.cmd}
style={{
padding: '8px 12px', borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
transition: 'background 0.15s'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={() => {
setInput(cmd.prompt);
setShowSlashMenu(false);
setSlashFilter('');
textareaRef.current?.focus();
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: textColor, minWidth: 80 }}>{cmd.cmd}</span>
<span style={{ fontSize: 13, fontWeight: 500, color: textColor }}>{cmd.label}</span>
<span style={{ fontSize: 11, color: mutedColor, marginLeft: 'auto' }}>{cmd.desc}</span>
</div>
))}
</div>
)}
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onKeyDown={handleKeyDown as any}
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
variant="filled"
value={activeProvider.model || undefined}
onChange={onModelChange}
onOpenChange={(open) => {
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
onFetchModels();
}
}}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
style={{ width: 130, fontSize: 11, background: 'transparent' }}
styles={{ popup: { root: { minWidth: 200 } } }}
showSearch
placeholder="选择模型"
/>
)}
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k时将触发自动压缩。`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
transition: 'all 0.3s'
}}>
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
</div>
</Tooltip>
)}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
<input
type="file"
accept="image/*"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
/>
<Tooltip title="上传图片/截图">
<Button
type="text"
icon={<PictureOutlined style={{ fontSize: 16 }} />}
onClick={() => fileInputRef.current?.click()}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
<Tooltip title="关联附带数据库表上下文">
<Button
type="text"
icon={<TableOutlined style={{ fontSize: 16 }} />}
onClick={handleOpenContext}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
{sending ? (
<button
className="ai-chat-send-btn ai-chat-stop-btn"
onClick={onStop}
title="停止生成"
style={{
background: 'rgba(255,77,79,0.1)',
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
width: 26, height: 26, borderRadius: 6, padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
}}
>
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
</button>
) : (
<button
className="ai-chat-send-btn"
onClick={() => onSend()}
disabled={!input.trim() && draftImages.length === 0}
title="发送"
style={{
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
}}
>
<SendOutlined />
</button>
)}
</div>
</div>
</div>
<Modal
title={<span style={{ color: textColor }}></span>}
open={contextOpen}
onCancel={() => setContextOpen(false)}
onOk={handleAppendContext}
confirmLoading={appendingContext}
okText="同步所选表至上下文"
cancelText="取消"
centered
styles={{
content: { background: darkMode ? '#1e1e1e' : '#ffffff', border: overlayTheme.shellBorder },
header: { background: darkMode ? '#1e1e1e' : '#ffffff', borderBottom: overlayTheme.shellBorder },
body: { padding: '20px 24px' }
}}
>
<Spin spinning={contextLoading}>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
{dbList.length > 0 && (
<Select
value={selectedDbName}
onChange={val => {
const c = useStore.getState().connections.find(conn => conn.id === activeContext?.connectionId);
if (c) fetchTablesForDb(val, c.config);
}}
options={dbList.map(d => ({ label: d, value: d }))}
style={{ width: 160, flexShrink: 0 }}
placeholder="切换数据库"
showSearch
/>
)}
<Input
placeholder="在当前库搜索表名..."
prefix={<SearchOutlined style={{ color: overlayTheme.mutedText }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ background: darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', border: 'none', flexGrow: 1 }}
/>
</div>
{filteredTables.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: `1px solid ${darkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`, paddingBottom: 12, marginBottom: 8 }}>
<Checkbox
indeterminate={
filteredTables.length > 0 &&
filteredTables.some(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`)) &&
!filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))
}
checked={filteredTables.length > 0 && filteredTables.every(t => selectedTableKeys.includes(`${selectedDbName}::${t.name}`))}
onChange={(e) => {
if (e.target.checked) {
const newSelected = new Set([...selectedTableKeys, ...filteredTables.map(t => `${selectedDbName}::${t.name}`)]);
setSelectedTableKeys(Array.from(newSelected));
} else {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
setSelectedTableKeys(selectedTableKeys.filter(key => !filteredKeys.includes(key)));
}
}}
style={{ color: textColor, fontWeight: 'bold' }}
>
({filteredTables.length})
</Checkbox>
<Button
type="link"
size="small"
style={{ padding: 0, height: 'auto', fontSize: 13 }}
onClick={() => {
const filteredKeys = filteredTables.map(t => `${selectedDbName}::${t.name}`);
const remainingSelected = selectedTableKeys.filter(key => !filteredKeys.includes(key));
const toAdd = filteredKeys.filter(key => !selectedTableKeys.includes(key));
setSelectedTableKeys([...remainingSelected, ...toAdd]);
}}
>
</Button>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', margin: '0 -24px', padding: '0 24px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredTables.map(t => {
const key = `${selectedDbName}::${t.name}`;
const isSelected = selectedTableKeys.includes(key);
return (
<div
key={key}
style={{
padding: '6px 10px',
borderRadius: 6,
transition: 'background 0.2s',
cursor: 'pointer'
}}
onMouseEnter={e => e.currentTarget.style.background = darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.03)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
onClick={(e) => {
if ((e.target as HTMLElement).tagName.toLowerCase() === 'input') return;
if (isSelected) {
setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
} else {
setSelectedTableKeys([...selectedTableKeys, key]);
}
}}
>
<Checkbox
checked={isSelected}
onChange={(e) => {
if (e.target.checked) setSelectedTableKeys([...selectedTableKeys, key]);
else setSelectedTableKeys(selectedTableKeys.filter(k => k !== key));
}}
style={{ color: textColor, width: '100%' }}
>
<span style={{ fontSize: 13, userSelect: 'none' }}>{t.name}</span>
</Checkbox>
</div>
);
})}
</div>
</div>
</div>
) : (
<div style={{ padding: '40px 0', textAlign: 'center', color: overlayTheme.mutedText }}>
'{searchText}'
</div>
)}
</Spin>
</Modal>
</div>
);
}
return (
<div className="ai-chat-input-area" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
<div className="ai-chat-input-area gn-v2-ai-composer" style={{ borderTop: 'none', padding: '12px 16px 20px' }}>
<div className="ai-chat-input-wrapper" style={{
borderColor: 'transparent',
background: 'transparent',
display: 'flex',
flexDirection: 'column',
@@ -256,40 +645,62 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
gap: 8,
padding: '8px 4px 8px'
}}>
<div className="ai-chat-input-preview-area" style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{activeContextItems.length > 0 && (
<Tag
onClick={() => setContextExpanded(!contextExpanded)}
style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }}
>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<DatabaseOutlined /> ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'}
</span>
</Tag>
)}
{contextExpanded && activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
style={{ background: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)', border: 'none', color: textColor, borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0 }}
>
<span style={{ fontSize: 13 }}>🗄 {ctx.tableName}</span>
</Tag>
))}
{draftImages.map((b64, i) => (
<div key={i} style={{ position: 'relative', width: 60, height: 60, borderRadius: 6, overflow: 'hidden', border: overlayTheme.shellBorder }}>
<img src={b64} style={{ width: '100%', height: '100%', objectFit: 'cover' }} alt={`Draft ${i}`} />
<div
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
style={{ position: 'absolute', top: 2, right: 2, background: 'rgba(0,0,0,0.5)', color: '#fff', borderRadius: '50%', width: 16, height: 16, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 10 }}
>
</div>
</div>
))}
<div className="ai-chat-input-preview-area gn-v2-ai-context-row">
<button
type="button"
className={`gn-v2-ai-context-toggle${contextExpanded ? ' is-expanded' : ''}`}
onClick={() => setContextExpanded(!contextExpanded)}
aria-expanded={contextExpanded}
>
<TableOutlined />
<span></span>
<strong>{activeContextItems.length}</strong>
<DownOutlined />
</button>
<button
type="button"
className="gn-v2-ai-context-add"
onClick={handleOpenContext}
>
<PlusOutlined />
<span></span>
</button>
</div>
{contextExpanded && activeContextItems.length > 0 && (
<div className="gn-v2-ai-context-detail" data-ai-context-detail="true">
<div className="gn-v2-ai-context-detail-title"> · {activeContextItems.length}</div>
{activeContextItems.map((ctx, idx) => (
<Tag
key={`ctx-${idx}`}
closable
onClose={(e) => { e.preventDefault(); removeAIContext(connectionKey, ctx.dbName, ctx.tableName); }}
className="gn-v2-ai-context-table-chip"
style={{ margin: 0 }}
>
<TableOutlined />
<span>{ctx.tableName}</span>
</Tag>
))}
</div>
)}
{draftImages.length > 0 && (
<div className="gn-v2-ai-attachment-row">
{draftImages.map((b64, i) => (
<div key={i} className="gn-v2-ai-attachment-thumb">
<img src={b64} alt={`Draft ${i}`} />
<button
type="button"
onClick={() => setDraftImages(prev => prev.filter((_, idx) => idx !== i))}
aria-label="移除图片"
>
</button>
</div>
))}
</div>
)}
{composerNotice && (
<div
data-ai-chat-composer-notice="true"
@@ -314,14 +725,11 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
</div>
)}
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
{showSlashMenu && filteredSlashCmds.length > 0 && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
<div className="gn-v2-ai-slash-menu" style={{
background: darkMode ? '#2a2a2a' : '#fff',
border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,0.15)', zIndex: 100,
maxHeight: 220, overflowY: 'auto', padding: 4
}}>
{filteredSlashCmds.map(cmd => (
<div
@@ -347,162 +755,151 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
))}
</div>
)}
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
<div className="gn-v2-ai-input-surface">
<Input.TextArea
onPaste={(e) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
const blob = items[i].getAsFile();
if (blob) {
const reader = new FileReader();
reader.onload = (event) => {
if (event.target?.result) {
setDraftImages(prev => [...prev, event.target!.result as string]);
}
};
reader.readAsDataURL(blob);
}
}
}
}
}}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
// Slash command detection
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onKeyDown={handleKeyDown as any}
placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)}Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
variant="filled"
value={activeProvider.model || undefined}
onChange={onModelChange}
onDropdownVisibleChange={(open) => {
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
onFetchModels();
}
}}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
style={{ width: 130, fontSize: 11, background: 'transparent' }}
dropdownStyle={{ minWidth: 200 }}
showSearch
placeholder="选择模型"
/>
)}
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k时将触发自动压缩。`}>
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 10, padding: '2px 6px', borderRadius: 12, border: '1px solid transparent',
background: contextUsageChars > maxContextChars * 0.8 ? (darkMode ? 'rgba(250, 173, 20, 0.1)' : 'rgba(250, 173, 20, 0.08)') : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'),
borderColor: contextUsageChars > maxContextChars * 0.8 ? 'rgba(250, 173, 20, 0.3)' : 'transparent',
color: contextUsageChars > maxContextChars * 0.8 ? '#faad14' : overlayTheme.mutedText, cursor: 'default',
transition: 'all 0.3s'
}}>
<span>🧠 {(contextUsageChars / 1000).toFixed(1)}k / {(maxContextChars / 1000).toFixed(0)}k</span>
</div>
</Tooltip>
)}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
<input
type="file"
accept="image/*"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
}}
ref={textareaRef as any}
value={input}
onChange={(e) => {
const val = e.target.value;
setInput(val);
// Slash command detection
if (val.startsWith('/')) {
setSlashFilter(val.split(/\s/)[0]);
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
setSlashFilter('');
}
}}
onKeyDown={handleKeyDown as any}
placeholder={`输入消息... ${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)} · / 命令`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
<Tooltip title="上传图片/截图">
<Button
type="text"
icon={<PictureOutlined style={{ fontSize: 16 }} />}
onClick={() => fileInputRef.current?.click()}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
<div className="gn-v2-ai-input-actions">
<input
type="file"
accept="image/*"
multiple
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleImageUpload}
/>
</Tooltip>
<Tooltip title="关联附带数据库表上下文">
<Button
type="text"
icon={<TableOutlined style={{ fontSize: 16 }} />}
onClick={handleOpenContext}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent', padding: '0 4px', height: 26 }}
onMouseEnter={e => e.currentTarget.style.color = textColor}
onMouseLeave={e => e.currentTarget.style.color = overlayTheme.mutedText}
/>
</Tooltip>
{sending ? (
<button
className="ai-chat-send-btn ai-chat-stop-btn"
onClick={onStop}
title="停止生成"
style={{
background: 'rgba(255,77,79,0.1)',
color: '#ff4d4f', border: '1px solid rgba(255,77,79,0.2)',
width: 26, height: 26, borderRadius: 6, padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0
}}
>
<div style={{ width: 10, height: 10, background: 'currentColor', borderRadius: 2 }} />
</button>
) : (
<button
className="ai-chat-send-btn"
onClick={() => onSend()}
disabled={!input.trim() && draftImages.length === 0}
title="发送"
style={{
background: (input.trim() || draftImages.length > 0) ? overlayTheme.iconBg : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)'),
color: (input.trim() || draftImages.length > 0) ? overlayTheme.iconColor : mutedColor,
width: 26, height: 26, borderRadius: 6, border: 'none', padding: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: (input.trim() || draftImages.length > 0) ? 'pointer' : 'not-allowed', flexShrink: 0
}}
>
<SendOutlined />
</button>
)}
<Tooltip title="上传图片/截图">
<Button
type="text"
icon={<PictureOutlined />}
onClick={() => fileInputRef.current?.click()}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
/>
</Tooltip>
<Tooltip title="关联附带数据库表上下文">
<Button
type="text"
icon={<TableOutlined />}
onClick={handleOpenContext}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
/>
</Tooltip>
<Tooltip title="快捷命令">
<Button
type="text"
icon={<CodeOutlined />}
onClick={() => {
setInput('/');
setSlashFilter('/');
setShowSlashMenu(true);
textareaRef.current?.focus();
}}
style={{ color: overlayTheme.mutedText, border: 'none', background: 'transparent' }}
/>
</Tooltip>
{sending ? (
<button
type="button"
className="ai-chat-send-btn ai-chat-stop-btn gn-v2-ai-send"
onClick={onStop}
title="停止生成"
>
<StopOutlined />
</button>
) : (
<button
type="button"
className="ai-chat-send-btn gn-v2-ai-send"
onClick={() => onSend()}
disabled={!input.trim() && draftImages.length === 0}
title="发送"
>
<SendOutlined />
</button>
)}
</div>
</div>
</div>
<div className="gn-v2-ai-model-bar">
{activeConnName && (
<Tooltip title="当前数据查询上下文">
<div className="gn-v2-ai-context-chip">
<span className="gn-v2-ai-context-live-dot" />
<DatabaseOutlined />
<span>{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}</span>
</div>
</Tooltip>
)}
{activeProvider && (
<Select
size="small"
value={activeProvider.model || undefined}
onChange={onModelChange}
onOpenChange={(open) => {
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
onFetchModels();
}
}}
loading={loadingModels}
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
styles={{ popup: { root: { minWidth: 200 } } }}
placeholder="选择模型"
className="gn-v2-ai-model-select"
suffixIcon={<DownOutlined />}
/>
)}
<div className="gn-v2-ai-model-spacer" />
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={`当前会话记忆已用字符。达到限制(${(maxContextChars/1000).toFixed(0)}k时将触发自动压缩。`}>
<div className={`gn-v2-ai-token-meter${contextUsageChars > maxContextChars * 0.8 ? ' is-warn' : ''}`}>
<span className="gn-v2-ai-token-bar" aria-hidden="true">
<span style={{ width: `${Math.min(100, (contextUsageChars / Math.max(1, maxContextChars)) * 100)}%` }} />
</span>
<span>{(contextUsageChars / 1000).toFixed(1)}k/{(maxContextChars / 1000).toFixed(0)}k</span>
</div>
</Tooltip>
)}
</div>
</div>
<Modal

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { RobotOutlined } from '@ant-design/icons';
import { ApiOutlined, DatabaseOutlined, FileTextOutlined, RobotOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
interface AIChatWelcomeProps {
@@ -10,15 +10,15 @@ interface AIChatWelcomeProps {
mutedColor: string;
onQuickAction: (prompt: string, autoSend?: boolean) => void;
contextTableNames?: string[];
isV2Ui?: boolean;
}
export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = []
overlayTheme, quickActionBg, quickActionBorder, textColor, mutedColor, onQuickAction, contextTableNames = [], isV2Ui = false
}) => {
const hasContext = contextTableNames.length > 0;
const tableList = contextTableNames.join('、');
const quickActions = hasContext
const legacyQuickActions = hasContext
? [
{ label: '📝 生成 SQL', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
{ label: '🔍 解释表结构', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
@@ -32,22 +32,82 @@ export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
{ label: '🏗️ Schema 分析', prompt: '请分析当前数据库的表结构并给出优化建议。' },
];
const quickActions = hasContext
? [
{ label: '生成 SQL', hint: '自然语言生成查询', icon: <FileTextOutlined />, tone: 'info', prompt: `请根据以下表结构生成一条常用查询语句:${tableList}` },
{ label: '解释表结构', hint: '逐字段说明含义与约束', icon: <DatabaseOutlined />, tone: 'success', prompt: `请详细解释以下表的设计意图和字段含义:${tableList}` },
{ label: '优化建议', hint: '索引、范式、潜在风险', icon: <ThunderboltOutlined />, tone: 'warn', prompt: `请分析以下表的结构设计,给出索引优化和查询性能优化建议:${tableList}` },
{ label: 'Schema 分析', hint: '表关系与依赖图', icon: <ApiOutlined />, tone: 'purple', prompt: `请对以下表进行全面的 Schema 分析,包括数据类型选择、范式评估和改进建议:${tableList}` },
]
: [
{ label: '生成 SQL', hint: '自然语言生成查询', icon: <FileTextOutlined />, tone: 'info', prompt: '请根据当前数据库表结构生成一条查询语句:' },
{ label: '解释 SQL', hint: '说明执行逻辑', icon: <DatabaseOutlined />, tone: 'success', prompt: '请解释以下 SQL 语句的执行逻辑:\n```sql\n\n```' },
{ label: '优化建议', hint: '性能和索引建议', icon: <ThunderboltOutlined />, tone: 'warn', prompt: '请分析以下 SQL 语句的性能并给出优化建议:\n```sql\n\n```' },
{ label: 'Schema 分析', hint: '结构质量分析', icon: <ApiOutlined />, tone: 'purple', prompt: '请分析当前数据库的表结构并给出优化建议。' },
];
const promptSuggestions = hasContext
? [
`为什么 ${contextTableNames[0]?.split('.').pop() || '当前表'} 只有少量记录?`,
'过去 7 天关键渠道的分布情况',
'帮我写一条禁用异常渠道的 SQL',
]
: [
'为什么当前结果只有少量记录?',
'过去 7 天订单渠道分布',
'帮我写一条清理异常数据的 SQL',
];
if (!isV2Ui) {
return (
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
GoNavi AI
</div>
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
{hasContext
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
</div>
<div className="quick-actions">
{legacyQuickActions.map(action => (
<div
key={action.label}
className="quick-action-btn"
style={{
background: quickActionBg,
borderColor: quickActionBorder,
color: textColor,
}}
onClick={() => onQuickAction(action.prompt)}
>
{action.label}
</div>
))}
</div>
</div>
);
}
return (
<div className="ai-chat-welcome" style={{ padding: '30px 20px', alignItems: 'flex-start', textAlign: 'left' }}>
<div style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<RobotOutlined style={{ marginRight: 8, color: overlayTheme.iconColor }} />
GoNavi AI
<div className="gn-v2-ai-welcome-title" style={{ color: overlayTheme.titleText, fontSize: 16, fontWeight: 600, marginBottom: 8 }}>
<span className="gn-v2-ai-welcome-icon">
<RobotOutlined style={{ color: overlayTheme.iconColor }} />
</span>
<strong> GoNavi AI</strong>
</div>
<div className="welcome-desc" style={{ color: mutedColor, fontSize: 13, lineHeight: 1.6, marginBottom: 20 }}>
{hasContext
? `已自动关联 ${contextTableNames.length} 张表结构,点击下方按钮快速开始分析。`
? <> <span className="gn-v2-ai-context-inline"><DatabaseOutlined />{contextTableNames.length} </span> </>
: '我是你的智能数据库助手。我可以帮你生成 SQL 查询、分析表结构、解释执行逻辑以及优化数据库性能。'}
</div>
<div className="quick-actions">
<div className="quick-actions gn-v2-ai-quick-grid">
{quickActions.map(action => (
<div
<button
type="button"
key={action.label}
className="quick-action-btn"
className={`quick-action-btn gn-v2-ai-quick-card tone-${action.tone}`}
style={{
background: quickActionBg,
borderColor: quickActionBorder,
@@ -55,8 +115,23 @@ export const AIChatWelcome: React.FC<AIChatWelcomeProps> = ({
}}
onClick={() => onQuickAction(action.prompt)}
>
{action.label}
</div>
<span className="gn-v2-ai-quick-icon">{action.icon}</span>
<strong>{action.label}</strong>
<span>{action.hint}</span>
</button>
))}
</div>
<div className="gn-v2-ai-suggestion-list">
<div className="gn-v2-ai-suggestion-divider">
<span />
<small></small>
<span />
</div>
{promptSuggestions.map((prompt) => (
<button key={prompt} type="button" onClick={() => onQuickAction(prompt)}>
<RobotOutlined />
<span>{prompt}</span>
</button>
))}
</div>
</div>

View File

@@ -59,7 +59,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
cancelText="关闭"
okButtonProps={{ disabled: !preview?.allowed, loading: applying }}
width={880}
destroyOnClose
destroyOnHidden
>
{!preview ? (
<Alert type="info" showIcon message="暂无预览结果" />

View File

@@ -1,5 +1,11 @@
import { resolveTextInputSafeBackdropFilter } from '../utils/appearance';
/** v2 = body[data-ui-version="v2"],由 App.tsx 切换。 */
const isV2 = (): boolean => {
if (typeof document === 'undefined' || !document.body) return false;
return document.body.getAttribute('data-ui-version') === 'v2';
};
type RedisWorkbenchThemeInput = {
darkMode: boolean;
opacity: number;
@@ -56,6 +62,77 @@ export const buildRedisWorkbenchTheme = ({
disableBackdropFilter ?? false,
);
// ─── v2 palette: Redis-red accent on GoNavi neutral surfaces ──
if (isV2()) {
if (darkMode) {
return {
isDark: true,
appBg: '#0c0e12',
panelBg: '#161a21',
panelBgStrong: '#1b1f27',
panelBgSubtle: 'rgba(255,255,255,0.04)',
panelBorder: '0.5px solid rgba(255,255,255,0.10)',
panelInset: 'inset 0 0.5px 0 rgba(255,255,255,0.05)',
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(248,113,113,0.18) 0%, rgba(248,113,113,0.08) 100%)',
contentEmptyBg: 'linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.01) 100%)',
textPrimary: '#f1f3f5',
textSecondary: '#d6dade',
textMuted: '#80868f',
accent: '#f87171',
accentSoft: 'rgba(248, 113, 113, 0.16)',
accentBorder: 'rgba(248, 113, 113, 0.32)',
actionSecondaryBg: 'rgba(255,255,255,0.05)',
actionSecondaryBorder: '0.5px solid rgba(255,255,255,0.10)',
actionDangerBg: 'rgba(220, 38, 38, 0.16)',
actionDangerBorder: '0.5px solid rgba(220, 38, 38, 0.32)',
actionDangerText: '#f87171',
statusTagBg: 'rgba(56, 189, 248, 0.16)',
statusTagBorder: '0.5px solid rgba(56, 189, 248, 0.30)',
statusTagMutedBg: 'rgba(255,255,255,0.06)',
statusTagMutedBorder: '0.5px solid rgba(255,255,255,0.10)',
treeHoverBg: 'rgba(255,255,255,0.05)',
treeSelectedBg: 'linear-gradient(90deg, rgba(248,113,113,0.18) 0%, rgba(248,113,113,0.06) 100%)',
treeSelectedBorder: 'rgba(248, 113, 113, 0.28)',
divider: 'rgba(255,255,255,0.06)',
shadow: '0 12px 40px rgba(0,0,0,0.55)',
backdropFilter,
};
}
return {
isDark: false,
appBg: '#f6f6f4',
panelBg: '#ffffff',
panelBgStrong: '#ffffff',
panelBgSubtle: '#fafaf8',
panelBorder: '0.5px solid rgba(15,23,42,0.12)',
panelInset: 'inset 0 0.5px 0 rgba(255,255,255,0.6)',
toolbarPrimaryBg: 'linear-gradient(135deg, rgba(220,38,38,0.10) 0%, rgba(220,38,38,0.04) 100%)',
contentEmptyBg: 'linear-gradient(180deg, rgba(15,23,42,0.02) 0%, rgba(15,23,42,0.01) 100%)',
textPrimary: '#0c1322',
textSecondary: '#1f2937',
textMuted: '#6b7280',
accent: '#dc2626',
accentSoft: 'rgba(220, 38, 38, 0.10)',
accentBorder: 'rgba(220, 38, 38, 0.24)',
actionSecondaryBg: '#ffffff',
actionSecondaryBorder: '0.5px solid rgba(15,23,42,0.12)',
actionDangerBg: 'rgba(220, 38, 38, 0.10)',
actionDangerBorder: '0.5px solid rgba(220, 38, 38, 0.24)',
actionDangerText: '#dc2626',
statusTagBg: 'rgba(2, 132, 199, 0.10)',
statusTagBorder: '0.5px solid rgba(2, 132, 199, 0.24)',
statusTagMutedBg: 'rgba(15,23,42,0.04)',
statusTagMutedBorder: '0.5px solid rgba(15,23,42,0.08)',
treeHoverBg: 'rgba(15,23,42,0.045)',
treeSelectedBg: 'linear-gradient(90deg, rgba(220,38,38,0.10) 0%, rgba(220,38,38,0.02) 100%)',
treeSelectedBorder: 'rgba(220, 38, 38, 0.22)',
divider: 'rgba(15,23,42,0.08)',
shadow: '0 12px 40px rgba(15,23,42,0.14)',
backdropFilter,
};
}
// ─── legacy palette (existing behavior) ──────────────────────
if (darkMode) {
const appTopAlpha = isTranslucent ? Math.max(0.08, Math.min(0.22, normalizedOpacity * 0.16)) : 0.92;
const appBottomAlpha = isTranslucent ? Math.max(0.12, Math.min(0.28, normalizedOpacity * 0.22)) : 0.96;