diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 11961e4..6d0461d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,7 +4,6 @@ import { Layout, Button, ConfigProvider, theme, message, Spin, Slider, Progress,
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined, FolderOpenOutlined, HddOutlined, SafetyCertificateOutlined, SwitcherOutlined, CodeOutlined, RightOutlined } from '@ant-design/icons';
import { BrowserOpenURL, Environment, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowUnfullscreen, WindowUnmaximise } from '../wailsjs/runtime';
import Sidebar from './components/Sidebar';
-import SlowQueryRailButton from './components/sidebar/SlowQueryRailButton';
import TabManager from './components/TabManager';
import ConnectionModal from './components/ConnectionModal';
import SnippetSettingsModal from './components/SnippetSettingsModal';
@@ -2088,13 +2087,23 @@ function App() {
const {
- handleCloseLogPanel,
+ handleCloseLogPanel: handleCloseLegacyLogPanel,
handleLogResizeStart,
- handleToggleLogPanel,
+ handleToggleLogPanel: toggleLegacyLogPanel,
isLogPanelOpen,
logGhostRef,
logPanelHeight,
} = useAppLogPanelResize();
+ const handleToggleLogPanel = useCallback(() => {
+ if (isV2Ui) {
+ window.dispatchEvent(new CustomEvent('gonavi:show-sql-execution-log'));
+ return;
+ }
+ toggleLegacyLogPanel();
+ }, [isV2Ui, toggleLegacyLogPanel]);
+ const handleCloseLogPanel = useCallback(() => {
+ handleCloseLegacyLogPanel();
+ }, [handleCloseLegacyLogPanel]);
const handleCreateConnection = useCallback(() => {
setSecurityUpdateRepairSource(null);
@@ -2887,18 +2896,6 @@ function App() {
onFocusCommandSearch={handleFocusSidebarSearch}
/>
- {/* 慢 SQL 历史入口:浮动在 Sidebar 右下角,独立组件不依赖 Sidebar 内部 state */}
-
{!connectionWorkbenchState.ready && (
)}
- {isLogPanelOpen && (
-
)}
diff --git a/frontend/src/components/LogPanel.test.tsx b/frontend/src/components/LogPanel.test.tsx
index 6f7b36e..720852c 100644
--- a/frontend/src/components/LogPanel.test.tsx
+++ b/frontend/src/components/LogPanel.test.tsx
@@ -82,15 +82,16 @@ vi.mock("@ant-design/icons", async () => {
ClearOutlined: Icon,
CloseOutlined: Icon,
ClockCircleOutlined: Icon,
+ RobotOutlined: Icon,
};
});
-const renderLogPanel = () => {
+const renderLogPanel = (props: Partial> = {}) => {
let renderer!: ReactTestRenderer;
act(() => {
renderer = create(
undefined}>
-
+
,
);
});
@@ -144,6 +145,36 @@ describe("LogPanel i18n", () => {
expect(renderedText).toContain("OK");
});
+ it("renders the current execution error summary inside the embedded log tab", () => {
+ storeState.sqlLogs = [
+ {
+ id: "log-err",
+ timestamp: Date.UTC(2026, 5, 20, 5, 40, 0),
+ sql: "SELECT * FROM message;",
+ status: "error",
+ duration: 18,
+ message: "driver exploded",
+ },
+ ];
+ const onDiagnoseExecutionError = vi.fn();
+ const renderer = renderLogPanel({
+ variant: "embedded",
+ executionError: "Table 'missav_bot.message' doesn't exist",
+ onDiagnoseExecutionError,
+ });
+ const renderedText = textContent(renderer.toJSON());
+
+ expect(renderedText).toContain("Execution failed");
+ expect(renderedText).toContain("Table 'missav_bot.message' doesn't exist");
+ expect(renderedText).toContain("AI diagnose");
+
+ const diagnoseButton = renderer.root.findAll((node) => node.type === "button" && textContent(node).includes("AI diagnose"))[0];
+ act(() => {
+ diagnoseButton.props.onClick?.();
+ });
+ expect(onDiagnoseExecutionError).toHaveBeenCalledTimes(1);
+ });
+
it("does not keep migrated Chinese UI literals in LogPanel source", () => {
const source = readFileSync(new URL("./LogPanel.tsx", import.meta.url), "utf8");
diff --git a/frontend/src/components/LogPanel.tsx b/frontend/src/components/LogPanel.tsx
index 5a4acc8..3894c9a 100644
--- a/frontend/src/components/LogPanel.tsx
+++ b/frontend/src/components/LogPanel.tsx
@@ -1,16 +1,26 @@
import React from 'react';
import { Table, Tag, Button, Tooltip, Empty } from 'antd';
-import { ClearOutlined, CloseOutlined, BugOutlined } from '@ant-design/icons';
+import { ClearOutlined, CloseOutlined, BugOutlined, RobotOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useI18n } from '../i18n/provider';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
interface LogPanelProps {
- height: number;
- onClose: () => void;
- onResizeStart: (e: React.MouseEvent) => void;
+ height?: number;
+ onClose?: () => void;
+ onResizeStart?: (e: React.MouseEvent) => void;
+ variant?: 'panel' | 'embedded';
+ executionError?: string;
+ onDiagnoseExecutionError?: () => void;
}
-const LogPanel: React.FC = ({ height, onClose, onResizeStart }) => {
+const LogPanel: React.FC = ({
+ height = 260,
+ onClose,
+ onResizeStart,
+ variant = 'panel',
+ executionError,
+ onDiagnoseExecutionError,
+}) => {
const { t } = useI18n();
const sqlLogs = useStore(state => state.sqlLogs);
const clearSqlLogs = useStore(state => state.clearSqlLogs);
@@ -49,6 +59,8 @@ const LogPanel: React.FC = ({ height, onClose, onResizeStart }) =
const logScrollbarThumbHover = darkMode
? `rgba(255, 255, 255, ${Math.max(0.28, opacity * 0.48)})`
: `rgba(0, 0, 0, ${Math.max(0.18, opacity * 0.36)})`;
+ const isEmbedded = variant === 'embedded';
+ const logCountLabel = sqlLogs.length.toLocaleString();
const columns = [
{
@@ -86,6 +98,189 @@ const LogPanel: React.FC = ({ height, onClose, onResizeStart }) =
}
];
+ const logTable = (
+
+ {sqlLogs.length === 0 ? (
+
+ {t('log_panel.empty')}}
+ />
+
+ ) : (
+
+ )}
+
+ );
+
+ const sharedStyles = (
+
+ );
+
+ if (isEmbedded) {
+ return (
+
+
+
+
+
+
+
+
+ {t('log_panel.description')}
+
+
+ {logCountLabel}
+
+
+
+
+ }
+ onClick={clearSqlLogs}
+ style={{ color: panelMutedTextColor }}
+ />
+
+
+ {executionError && (
+
+
+
+
+ {t('query_editor.result.execution_failed')}
+
+
+ {executionError}
+
+ {onDiagnoseExecutionError && (
+
+ }
+ onClick={onDiagnoseExecutionError}
+ style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
+ >
+ {t('query_editor.result.ai_diagnose')}
+
+
+ )}
+
+
+ )}
+ {logTable}
+ {sharedStyles}
+
+ );
+ }
+
return (
= ({ height, onClose, onResizeStart }) =
zIndex: 100
}}>
{/* Resize Handle */}
-
+ {onResizeStart && (
+
+ )}
{/* Toolbar */}
= ({ height, onClose, onResizeStart }) =
} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
-
- } onClick={onClose} style={{ color: panelMutedTextColor }} />
-
+ {onClose && (
+
+ } onClick={onClose} style={{ color: panelMutedTextColor }} />
+
+ )}
- {/* List */}
-
- {sqlLogs.length === 0 ? (
-
- {t('log_panel.empty')}}
- />
-
- ) : (
-
- )}
-
-
+ {logTable}
+ {sharedStyles}
);
};
diff --git a/frontend/src/components/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
index 4329786..16a2194 100644
--- a/frontend/src/components/QueryEditor.external-sql-save.test.tsx
+++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readV2ThemeCss } from '../test/readV2ThemeCss';
import { setCurrentLanguage } from '../i18n';
+import { I18nProvider } from '../i18n/provider';
import type { SavedQuery, TabData } from '../types';
import { ORACLE_ROWID_LOCATOR_COLUMN } from '../utils/rowLocator';
import { clearQueryTabDraft, clearSQLFileTabDraft, getQueryTabDraft, getSQLFileTabDraft } from '../utils/sqlFileTabDrafts';
@@ -29,6 +30,14 @@ const storeState = vi.hoisted(() => ({
},
},
],
+ sqlLogs: [] as Array<{
+ id: string;
+ timestamp: number;
+ sql: string;
+ status: 'success' | 'error';
+ duration: number;
+ }>,
+ clearSqlLogs: vi.fn(),
addSqlLog: vi.fn(),
addTab: vi.fn(),
setActiveContext: vi.fn(),
@@ -348,9 +357,20 @@ vi.mock('./DataGrid', () => ({
GONAVI_ROW_KEY: '__gonavi_row_key__',
}));
+vi.mock('./LogPanel', () => ({
+ default: ({ variant, executionError }: { variant?: string; executionError?: string }) => (
+
+ SQL 执行日志
+ {executionError ? ` ${executionError}` : ''}
+
+ ),
+}));
+
vi.mock('@ant-design/icons', () => {
const Icon = () => ;
return {
+ BugOutlined: Icon,
+ ClearOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -377,10 +397,30 @@ vi.mock('antd', () => {
const Form: any = ({ children }: any) => ;
Form.Item = ({ children }: any) => <>{children}>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
+ const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
+
+ {dataSource.map((record) => (
+
+ {columns.map((column) => (
+
+ {column.render
+ ? column.render(record[column.dataIndex], record)
+ : record[column.dataIndex]}
+
+ ))}
+
+ ))}
+
+ );
+ const Empty = ({ description }: { description?: React.ReactNode }) => {description}
;
+ (Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
return {
Button,
Space,
+ Table,
+ Tag: ({ children }: { children?: React.ReactNode }) => {children},
+ Empty,
message: messageApi,
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
@@ -609,6 +649,8 @@ describe('QueryEditor external SQL save', () => {
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections = createDefaultConnections();
+ storeState.sqlLogs = [];
+ storeState.clearSqlLogs.mockReset();
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
storeState.appearance.uiVersion = 'legacy';
@@ -667,7 +709,11 @@ describe('QueryEditor external SQL save', () => {
let renderer!: ReactTestRenderer;
await act(async () => {
- renderer = create();
+ renderer = create(
+ undefined}>
+
+ ,
+ );
});
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
@@ -678,7 +724,11 @@ describe('QueryEditor external SQL save', () => {
let renderer!: ReactTestRenderer;
await act(async () => {
- renderer = create();
+ renderer = create(
+ undefined}>
+
+ ,
+ );
});
await act(async () => {
@@ -902,6 +952,90 @@ describe('QueryEditor external SQL save', () => {
renderer.unmount();
});
+ it('opens the embedded sql execution log tab from the shared log toggle event in v2', async () => {
+ storeState.appearance.uiVersion = 'v2';
+ storeState.sqlLogs = [{
+ id: 'log-1',
+ timestamp: Date.now(),
+ sql: 'select 1',
+ status: 'success',
+ duration: 12,
+ }];
+
+ const windowListeners: Record void)[]> = {};
+ vi.stubGlobal('window', {
+ addEventListener: vi.fn((type: string, listener: (event?: any) => void) => {
+ windowListeners[type] ||= [];
+ windowListeners[type].push(listener);
+ }),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
+ callback(0);
+ return 1;
+ }),
+ cancelAnimationFrame: vi.fn(),
+ innerHeight: 900,
+ });
+
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ expect(textContent(renderer.toJSON())).not.toContain('SQL 执行日志');
+
+ await act(async () => {
+ windowListeners['gonavi:show-sql-execution-log']?.forEach((listener) => listener());
+ });
+
+ expect(textContent(renderer.toJSON())).toContain('SQL 执行日志');
+ expect(storeState.updateQueryTabDraft).toHaveBeenCalledWith('tab-1', {
+ resultPanelVisible: true,
+ });
+
+ await act(async () => {
+ windowListeners['gonavi:show-sql-execution-log']?.forEach((listener) => listener());
+ });
+
+ expect(textContent(renderer.toJSON())).not.toContain('SQL 执行日志');
+ expect(storeState.updateQueryTabDraft).toHaveBeenLastCalledWith('tab-1', {
+ resultPanelVisible: false,
+ });
+
+ renderer.unmount();
+ });
+
+ it('shows execution failures inside the embedded sql log tab in v2', async () => {
+ storeState.appearance.uiVersion = 'v2';
+ backendApp.DBQueryMulti.mockResolvedValueOnce({
+ success: false,
+ message: 'driver exploded',
+ data: [],
+ });
+
+ let renderer!: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ await act(async () => {
+ await findButton(renderer, '运行').props.onClick();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ const rendered = textContent(renderer.toJSON());
+ expect(rendered).toContain('SQL 执行日志');
+ expect(rendered).toContain('driver exploded');
+ expect(renderer.root.findAll((node) => node.props?.['data-log-panel'] === 'embedded')).toHaveLength(1);
+ expect(renderer.root.findAll((node) => node.props?.['data-tab-key'] === '__gonavi_sql_execution_log__')).toHaveLength(1);
+
+ renderer.unmount();
+ });
+
it('keeps query result panel visibility isolated per tab', async () => {
storeState.appearance.uiVersion = 'v2';
storeState.queryOptions.showQueryResultsPanel = false;
diff --git a/frontend/src/components/QueryEditor.results-and-drop.test.tsx b/frontend/src/components/QueryEditor.results-and-drop.test.tsx
index b339419..313d9f2 100644
--- a/frontend/src/components/QueryEditor.results-and-drop.test.tsx
+++ b/frontend/src/components/QueryEditor.results-and-drop.test.tsx
@@ -29,6 +29,14 @@ const storeState = vi.hoisted(() => ({
},
},
],
+ sqlLogs: [] as Array<{
+ id: string;
+ timestamp: number;
+ sql: string;
+ status: 'success' | 'error';
+ duration: number;
+ }>,
+ clearSqlLogs: vi.fn(),
addSqlLog: vi.fn(),
addTab: vi.fn(),
setActiveContext: vi.fn(),
@@ -351,6 +359,8 @@ vi.mock('./DataGrid', () => ({
vi.mock('@ant-design/icons', () => {
const Icon = () => ;
return {
+ BugOutlined: Icon,
+ ClearOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -377,10 +387,30 @@ vi.mock('antd', () => {
const Form: any = ({ children }: any) => ;
Form.Item = ({ children }: any) => <>{children}>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
+ const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
+
+ {dataSource.map((record) => (
+
+ {columns.map((column) => (
+
+ {column.render
+ ? column.render(record[column.dataIndex], record)
+ : record[column.dataIndex]}
+
+ ))}
+
+ ))}
+
+ );
+ const Empty = ({ description }: { description?: React.ReactNode }) => {description}
;
+ (Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
return {
Button,
Space,
+ Table,
+ Tag: ({ children }: { children?: React.ReactNode }) => {children},
+ Empty,
message: messageApi,
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
@@ -609,6 +639,8 @@ describe('QueryEditor external SQL save', () => {
backendApp.DBGetTables.mockResolvedValue({ success: true, data: [] });
backendApp.GenerateQueryID.mockResolvedValue('query-1');
storeState.connections = createDefaultConnections();
+ storeState.sqlLogs = [];
+ storeState.clearSqlLogs.mockReset();
storeState.connections[0].config.type = 'mysql';
storeState.connections[0].config.database = 'main';
storeState.appearance.uiVersion = 'legacy';
@@ -2303,6 +2335,20 @@ describe('QueryEditor external SQL save', () => {
expect(css).toContain('body[data-ui-version="v2"] .gn-v2-query-results .query-result-tab-text {');
});
+ it('embeds the sql execution log as a result tab instead of a standalone workspace panel in v2', () => {
+ const panelSource = readFileSync(new URL('./QueryEditorResultsPanel.tsx', import.meta.url), 'utf8');
+ const editorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
+
+ expect(panelSource).toContain('QUERY_EDITOR_SQL_LOG_TAB_KEY');
+ expect(panelSource).toContain(' {
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');
diff --git a/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts b/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts
new file mode 100644
index 0000000..072190a
--- /dev/null
+++ b/frontend/src/components/QueryEditor.sql-analysis-workbench.test.ts
@@ -0,0 +1,40 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+describe('SQL analysis workbench wiring', () => {
+ it('routes QueryEditor diagnose and slow-query actions to the sql-analysis workbench tab', () => {
+ const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8')
+
+ expect(source).toContain("buildSqlAnalysisWorkbenchTab")
+ expect(source).toContain("openSqlAnalysisWorkbench('diagnose', getCurrentQuery())")
+ expect(source).toContain("openSqlAnalysisWorkbench('slow-query')")
+ expect(source).not.toContain('const [explainOpen, setExplainOpen]')
+ expect(source).not.toContain('const [slowQueryOpen, setSlowQueryOpen]')
+ expect(source).not.toContain(' {
+ const source = readFileSync(new URL('./sidebar/SlowQueryRailButton.tsx', import.meta.url), 'utf8')
+
+ expect(source).toContain('buildSqlAnalysisWorkbenchTab')
+ expect(source).toContain("view: 'slow-query'")
+ expect(source).not.toContain('SlowQueryPanel')
+ })
+
+ it('uses a compact segmented switcher in the sql-analysis workbench header', () => {
+ const source = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8')
+
+ expect(source).toContain('Segmented')
+ expect(source).toContain('gn-sql-analysis-view-switcher')
+ expect(source).not.toContain(' {
+ const source = readFileSync(new URL('./explain/ExplainWorkbench.tsx', import.meta.url), 'utf8')
+
+ expect(source).toContain('Segmented')
+ expect(source).toContain('gn-explain-report-switcher')
+ expect(source).not.toContain(' import('./explain/ExplainWorkbench'));
-// 慢 SQL 历史面板:lazy 加载
-const SlowQueryPanel = lazy(() => import('./explain/SlowQueryPanel'));
import { SUPPORTED_LANGUAGES, t as translate } from '../i18n';
+import { buildSqlAnalysisWorkbenchTab } from '../utils/sqlAnalysisTab';
import {
DUCKDB_ROWID_LOCATOR_COLUMN,
ORACLE_ROWID_LOCATOR_COLUMN,
@@ -48,7 +45,10 @@ import {
getColumnDefinitionName,
getColumnDefinitionType,
} from '../utils/columnDefinition';
-import QueryEditorResultsPanel, { type QueryEditorResultSet } from './QueryEditorResultsPanel';
+import QueryEditorResultsPanel, {
+ QUERY_EDITOR_SQL_LOG_TAB_KEY,
+ type QueryEditorResultSet,
+} from './QueryEditorResultsPanel';
import { SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS } from './QueryEditorTransactionSettings';
import QueryEditorTransactionToolbar from './QueryEditorTransactionToolbar';
import QueryEditorToolbar from './QueryEditorToolbar';
@@ -230,10 +230,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
const [saveModalMode, setSaveModalMode] = useState<'save' | 'rename'>('save');
const [saveForm] = Form.useForm();
- // SQL 诊断工作台与慢 SQL 历史:通过快捷键管理系统注册(避免与 toggleTheme/toggleLogPanel 冲突)
- const [explainOpen, setExplainOpen] = useState(false);
- const [slowQueryOpen, setSlowQueryOpen] = useState(false);
-
// Database Selection
const [currentConnectionId, setCurrentConnectionId] = useState(tab.connectionId);
const [currentDb, setCurrentDb] = useState(tab.dbName || '');
@@ -278,22 +274,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
[connections]
);
- // SQL 诊断工作台:从 currentConnectionId 解析 ConnectionConfig(复用 SavedConnection 模式)
- const explainConfig = useMemo(() => {
- if (!currentConnectionId) return null;
- const conn = connections.find(c => c.id === currentConnectionId);
- if (!conn) return null;
- return {
- ...conn.config,
- port: Number(conn.config.port),
- password: conn.config.password || '',
- database: conn.config.database || '',
- useSSH: conn.config.useSSH || false,
- ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
- } as any;
- }, [connections, currentConnectionId]);
-
const addSqlLog = useStore(state => state.addSqlLog);
+ const sqlLogCount = useStore(state => state.sqlLogs.length);
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const updateQueryTabDraft = useStore(state => state.updateQueryTabDraft);
@@ -334,23 +316,41 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
[activeShortcutPlatform, shortcutOptions],
);
+ const openSqlAnalysisWorkbench = useCallback(
+ (view: 'diagnose' | 'slow-query', nextSql?: string) => {
+ const connectionId = String(currentConnectionId || '').trim();
+ if (!connectionId) {
+ message.warning(translate('query_editor.message.connection_not_found'));
+ return;
+ }
+ const dbName = String(currentDb || tab.dbName || '').trim();
+ addTab(buildSqlAnalysisWorkbenchTab({
+ connectionId,
+ dbName: dbName || undefined,
+ query: typeof nextSql === 'string' && nextSql.trim() ? nextSql : undefined,
+ view,
+ }));
+ },
+ [addTab, currentConnectionId, currentDb, tab.dbName],
+ );
+
// SQL 诊断 / 慢 SQL 历史的快捷键监听(必须在 binding 声明之后)
useEffect(() => {
if (!isActive) return;
const handler = (e: KeyboardEvent) => {
if (diagnoseQueryShortcutBinding?.enabled && isShortcutMatch(e, diagnoseQueryShortcutBinding.combo)) {
e.preventDefault();
- setExplainOpen(true);
+ openSqlAnalysisWorkbench('diagnose', getCurrentQuery());
return;
}
if (showSlowQueriesShortcutBinding?.enabled && isShortcutMatch(e, showSlowQueriesShortcutBinding.combo)) {
e.preventDefault();
- setSlowQueryOpen(true);
+ openSqlAnalysisWorkbench('slow-query');
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
- }, [isActive, diagnoseQueryShortcutBinding, showSlowQueriesShortcutBinding]);
+ }, [diagnoseQueryShortcutBinding, isActive, openSqlAnalysisWorkbench, showSlowQueriesShortcutBinding]);
const selectCurrentStatementShortcutBinding = useMemo(
() => resolveShortcutBinding(shortcutOptions, 'selectCurrentStatement', activeShortcutPlatform),
[activeShortcutPlatform, shortcutOptions],
@@ -381,6 +381,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return nextVisible;
});
}, [tab.id, updateQueryTabDraft]);
+ const handleShowSqlExecutionLog = useCallback(() => {
+ if (!isActive) {
+ return;
+ }
+ if (isResultPanelVisible && activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY) {
+ updateResultPanelVisibility(false);
+ return;
+ }
+ updateResultPanelVisibility(true);
+ setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY);
+ }, [activeResultKey, isActive, isResultPanelVisible, updateResultPanelVisibility]);
const sqlEditorCommitMode = sqlEditorTransactionOptions?.commitMode === 'auto' ? 'auto' : 'manual';
const sqlEditorAutoCommitDelayMs = SQL_EDITOR_AUTO_COMMIT_DELAY_OPTIONS.some((item) => item.value === sqlEditorTransactionOptions?.autoCommitDelayMs)
? Number(sqlEditorTransactionOptions?.autoCommitDelayMs)
@@ -2960,15 +2971,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
dbName: currentDb
});
if (!res.success) {
- const prefix = statements.length > 1
- ? translate('query_editor.message.statement_failed_prefix', { index: idx + 1 })
- : '';
- updateResultPanelVisibility(true);
- setExecutionError(formatSqlExecutionError(res.message, { prefix }));
- setResultSets([]);
- setActiveResultKey('');
- return;
- }
+ const prefix = statements.length > 1
+ ? translate('query_editor.message.statement_failed_prefix', { index: idx + 1 })
+ : '';
+ updateResultPanelVisibility(true);
+ setExecutionError(formatSqlExecutionError(res.message, { prefix }));
+ setResultSets([]);
+ setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY);
+ return;
+ }
if (Array.isArray(res.data)) {
let rows = (res.data as any[]) || [];
let truncated = false;
@@ -3223,7 +3234,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
updateResultPanelVisibility(true);
setExecutionError(formatSqlExecutionError(res.message));
setResultSets([]);
- setActiveResultKey('');
+ setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY);
return;
}
@@ -3400,7 +3411,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
updateResultPanelVisibility(true);
setExecutionError(formattedError);
setResultSets([]);
- setActiveResultKey('');
+ setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY);
} finally {
if (runSeqRef.current === runSeq) setLoading(false);
// Clear query ID after execution completes
@@ -3881,7 +3892,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
)}
),
- onClick: () => setExplainOpen(true),
+ onClick: () => openSqlAnalysisWorkbench('diagnose', getCurrentQuery()),
},
{
key: 'show-slow-queries',
@@ -3895,7 +3906,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
)}
),
- onClick: () => setSlowQueryOpen(true),
+ onClick: () => openSqlAnalysisWorkbench('slow-query'),
},
];
@@ -3979,6 +3990,17 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
};
}, [isActive, handleQuickSave]);
+ useEffect(() => {
+ const handleOpenSqlExecutionLog = () => {
+ handleShowSqlExecutionLog();
+ };
+
+ window.addEventListener('gonavi:show-sql-execution-log', handleOpenSqlExecutionLog as EventListener);
+ return () => {
+ window.removeEventListener('gonavi:show-sql-execution-log', handleOpenSqlExecutionLog as EventListener);
+ };
+ }, [handleShowSqlExecutionLog]);
+
const handleSave = async () => {
try {
const values = await saveForm.validateFields();
@@ -4178,6 +4200,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
activeResultKey={activeResultKey}
loading={loading}
executionError={executionError}
+ sqlLogCount={sqlLogCount}
darkMode={darkMode}
isV2Ui={isV2Ui}
currentDb={currentDb}
@@ -4211,31 +4234,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
- {/* SQL 诊断工作台:Ctrl+Shift+D 触发,lazy 加载避免 reactflow 进入主 bundle */}
-
- {explainOpen && explainConfig && (
- setExplainOpen(false)}
- config={explainConfig}
- dbName={currentDb}
- sql={query}
- />
- )}
-
-
- {/* 慢 SQL 历史:Ctrl+Shift+H 触发 */}
-
- {slowQueryOpen && explainConfig && (
- setSlowQueryOpen(false)}
- config={explainConfig}
- dbName={currentDb}
- onPickQuery={(sql) => setQuery(sql)}
- />
- )}
-
);
};
diff --git a/frontend/src/components/QueryEditorResultsPanel.tsx b/frontend/src/components/QueryEditorResultsPanel.tsx
index 92d8e0b..7ecc8ce 100644
--- a/frontend/src/components/QueryEditorResultsPanel.tsx
+++ b/frontend/src/components/QueryEditorResultsPanel.tsx
@@ -1,12 +1,15 @@
import React from 'react';
import { Button, Dropdown, Tabs, Tooltip, type MenuProps } from 'antd';
-import { CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons';
+import { BugOutlined, CloseOutlined, EyeInvisibleOutlined, RobotOutlined } from '@ant-design/icons';
import type { EditRowLocator } from '../utils/rowLocator';
import type { QueryResultPaginationState } from '../utils/queryResultPagination';
import { t as defaultTranslate } from '../i18n';
import { useOptionalI18n } from '../i18n/provider';
import DataGrid from './DataGrid';
+import LogPanel from './LogPanel';
+
+export const QUERY_EDITOR_SQL_LOG_TAB_KEY = '__gonavi_sql_execution_log__';
export type QueryEditorResultSet = {
key: string;
@@ -33,6 +36,7 @@ interface QueryEditorResultsPanelProps {
activeResultKey: string;
loading: boolean;
executionError: string;
+ sqlLogCount: number;
darkMode: boolean;
isV2Ui: boolean;
currentDb: string;
@@ -58,6 +62,7 @@ const QueryEditorResultsPanel: React.FC = ({
activeResultKey,
loading,
executionError,
+ sqlLogCount,
darkMode,
isV2Ui,
currentDb,
@@ -76,44 +81,253 @@ const QueryEditorResultsPanel: React.FC = ({
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? defaultTranslate;
- const resolvedActiveResultKey = activeResultKey || resultSets[0]?.key || '';
+ const shouldShowSqlLogTab = sqlLogCount > 0 || activeResultKey === QUERY_EDITOR_SQL_LOG_TAB_KEY;
+ const logTabCountLabel = sqlLogCount > 999 ? '999+' : String(sqlLogCount);
+ const resolvedResultSetKey = activeResultKey && resultSets.some((rs) => rs.key === activeResultKey)
+ ? activeResultKey
+ : (resultSets[0]?.key || '');
+ const hideTooltipTitle = toggleShortcutLabel
+ ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel })
+ : t('query_editor.results_panel.tooltip.hide');
+ const toolbarHideButton = (
+
+ }
+ onClick={onHide}
+ >
+ {t('query_editor.results_panel.action.hide')}
+ {isV2Ui && toggleShortcutLabel && (
+ {toggleShortcutLabel}
+ )}
+
+
+ );
+
+ function buildResultTabMenuItems(key: string, index: number): MenuProps['items'] {
+ return [
+ {
+ key: 'close-other',
+ label: t('query_editor.results_panel.menu.close_other'),
+ disabled: resultSets.length <= 1,
+ onClick: () => onCloseOtherResultTabs(key),
+ },
+ {
+ key: 'close-left',
+ label: t('query_editor.results_panel.menu.close_left'),
+ disabled: index <= 0,
+ onClick: () => onCloseResultTabsToLeft(key),
+ },
+ {
+ key: 'close-right',
+ label: t('query_editor.results_panel.menu.close_right'),
+ disabled: index >= resultSets.length - 1,
+ onClick: () => onCloseResultTabsToRight(key),
+ },
+ { type: 'divider' },
+ {
+ key: 'close-all',
+ label: t('query_editor.results_panel.menu.close_all'),
+ disabled: resultSets.length === 0,
+ onClick: onCloseAllResultTabs,
+ },
+ ];
+ }
+
+ const buildResultTabItems = () => resultSets.map((rs, idx) => ({
+ key: rs.key,
+ label: (
+
+ {
+ event.preventDefault();
+ }}
+ >
+
+
+ {rs.resultType === 'message'
+ ? t('query_editor.results_panel.tab.message', { index: idx + 1 })
+ : t('query_editor.results_panel.tab.result', { index: idx + 1 })}
+
+
+ {(() => {
+ if (rs.resultType === 'message') {
+ return i;
+ }
+ if (isAffectedRowsResult(rs)) {
+ return ✓;
+ }
+ if (!Array.isArray(rs.rows)) {
+ return null;
+ }
+ return {rs.rows.length};
+ })()}
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onCloseResult(rs.key);
+ }}
+ >
+
+
+
+
+
+ ),
+ children: (() => {
+ if (rs.resultType === 'message') {
+ return (
+
+
{t('query_editor.results_panel.message.title')}
+
+ {(rs.messages || []).join('\n')}
+
+
+ );
+ }
+ if (isAffectedRowsResult(rs)) {
+ const affected = Number(rs.rows[0]?.affectedRows ?? 0);
+ return (
+
+
✓
+
{t('query_editor.result.execution_success')}
+
{t('query_editor.result.affected_rows', { count: affected })}
+ {Array.isArray(rs.messages) && rs.messages.length > 0 && (
+
+ {rs.messages.join('\n')}
+
+ )}
+
+ );
+ }
+ return (
+
+ {Array.isArray(rs.messages) && rs.messages.length > 0 && (
+
+ {rs.messages.join('\n')}
+
+ )}
+
{
+ if (rs.page) {
+ onResultPageChange(rs.key, rs.page.current, rs.page.pageSize);
+ return;
+ }
+ onReloadResult(rs.key, rs.sql);
+ }}
+ pagination={rs.page ? {
+ current: rs.page.current,
+ pageSize: rs.page.pageSize,
+ total: rs.page.total,
+ totalKnown: rs.page.totalKnown,
+ } : undefined}
+ onPageChange={rs.page ? ((page, size) => onResultPageChange(rs.key, page, size)) : undefined}
+ readOnly={rs.readOnly}
+ toolbarExtraActions={resolvedResultSetKey === rs.key ? toolbarHideButton : null}
+ />
+
+ );
+ })(),
+ }));
+
+ const resultTabItems = buildResultTabItems();
+ const logTabItem = shouldShowSqlLogTab
+ ? {
+ key: QUERY_EDITOR_SQL_LOG_TAB_KEY,
+ label: (
+
+
+
+ {t('log_panel.short_title')}
+ {logTabCountLabel}
+
+
+ ),
+ children: (
+
+ ),
+ }
+ : null;
+ const tabItems = logTabItem ? [logTabItem, ...resultTabItems] : resultTabItems;
+
+ const resolvedActiveResultKey = (() => {
+ if (activeResultKey && tabItems.some((item) => item.key === activeResultKey)) {
+ return activeResultKey;
+ }
+ if (resultSets[0]?.key) {
+ return resultSets[0].key;
+ }
+ return shouldShowSqlLogTab ? QUERY_EDITOR_SQL_LOG_TAB_KEY : '';
+ })();
const activeResultSet = resultSets.find((rs) => rs.key === resolvedActiveResultKey) || null;
const activeResultUsesDataGrid = Boolean(
activeResultSet &&
activeResultSet.resultType !== 'message' &&
!isAffectedRowsResult(activeResultSet),
);
- const hideTooltipTitle = toggleShortcutLabel
- ? t('query_editor.results_panel.tooltip.hide_with_shortcut', { shortcut: toggleShortcutLabel })
- : t('query_editor.results_panel.tooltip.hide');
-
- const buildResultTabMenuItems = (key: string, index: number): MenuProps['items'] => [
- {
- key: 'close-other',
- label: t('query_editor.results_panel.menu.close_other'),
- disabled: resultSets.length <= 1,
- onClick: () => onCloseOtherResultTabs(key),
- },
- {
- key: 'close-left',
- label: t('query_editor.results_panel.menu.close_left'),
- disabled: index <= 0,
- onClick: () => onCloseResultTabsToLeft(key),
- },
- {
- key: 'close-right',
- label: t('query_editor.results_panel.menu.close_right'),
- disabled: index >= resultSets.length - 1,
- onClick: () => onCloseResultTabsToRight(key),
- },
- { type: 'divider' },
- {
- key: 'close-all',
- label: t('query_editor.results_panel.menu.close_all'),
- disabled: resultSets.length === 0,
- onClick: onCloseAllResultTabs,
- },
- ];
const hideButton = (
@@ -151,21 +365,6 @@ const QueryEditorResultsPanel: React.FC = ({
}
: undefined;
- const toolbarHideButton = (
-
- }
- onClick={onHide}
- >
- {t('query_editor.results_panel.action.hide')}
- {isV2Ui && toggleShortcutLabel && (
- {toggleShortcutLabel}
- )}
-
-
- );
-
return (
<>
+ {loading && (
+
+
+
+ )}
+ {error && (
+
+ 诊断失败:
+ {error}
+
+ )}
+ {!loading && !error && !report && !hasRequestedRun && (
+
+ )}
+ {!loading && !error && report && (
+
+
+
setActiveView(value as 'plan' | 'raw')}
+ className="gn-explain-report-switcher"
+ options={[
+ {
+ value: 'plan',
+ label: (
+
+
+ 执行计划
+
+ ),
+ },
+ {
+ value: 'raw',
+ label: (
+
+
+ 原文
+
+ ),
+ },
+ ]}
+ />
+
+ {report.plan.nodes.length} 节点
+ /
+ {report.plan.rawFormat}
+
+
+
+
+ {activeView === 'plan' ? (
+
+ ) : (
+
+ {report.plan.rawPayload || '(无原文)'}
+
+ )}
+
+
+ )}
+
+ )
+}
+
+export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
return (
SQL 诊断工作台}
destroyOnClose
>
-
- {loading && (
-
-
-
- )}
- {error && (
-
- 诊断失败:
- {error}
-
- )}
- {!loading && !error && report && (
-
-
-
-
-
-
-
-
- ),
- },
- {
- key: 'raw',
- label: `原文(${report.plan.rawFormat})`,
- children: (
-
- {report.plan.rawPayload || '(无原文)'}
-
- ),
- },
- ]}
- />
- )}
+
+
)
}
+
+const reportViewStyles = `
+ .gn-explain-report-view {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .gn-explain-report-shell {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .gn-explain-report-switcher-row {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 12px;
+ flex-wrap: wrap;
+ }
+ .gn-explain-report-switcher {
+ flex: 0 0 auto;
+ }
+ .gn-explain-report-switcher-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-width: 88px;
+ white-space: nowrap;
+ }
+ .gn-explain-report-switcher .ant-segmented-group {
+ display: inline-flex;
+ align-items: center;
+ }
+ .gn-explain-report-switcher .ant-segmented-item {
+ min-height: 30px;
+ }
+ .gn-explain-report-switcher .ant-segmented-item-label {
+ padding: 5px 12px;
+ font-size: 13px;
+ line-height: 20px;
+ }
+ .gn-explain-report-switcher-meta {
+ flex: 0 0 auto;
+ white-space: nowrap;
+ }
+ .gn-explain-report-switcher-meta-separator {
+ display: inline-block;
+ margin: 0 6px;
+ }
+ .gn-explain-report-content {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .gn-explain-plan-view {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ gap: 12px;
+ }
+ .gn-explain-plan-graph {
+ flex: 1 1 auto;
+ min-width: 320px;
+ min-height: 0;
+ position: relative;
+ }
+ .gn-explain-plan-sidebar {
+ width: 320px;
+ flex: 0 0 320px;
+ min-height: 0;
+ overflow-y: auto;
+ }
+`
diff --git a/frontend/src/components/explain/SlowQueryPanel.tsx b/frontend/src/components/explain/SlowQueryPanel.tsx
index eb7fb14..046cc64 100644
--- a/frontend/src/components/explain/SlowQueryPanel.tsx
+++ b/frontend/src/components/explain/SlowQueryPanel.tsx
@@ -8,7 +8,7 @@ import { formatMs, formatNumber } from '../../utils/explainTypes'
// 慢 SQL 历史面板。
// 从 GetSlowQueries 加载 TopN,按 duration / rowsRead / recent 切换排序。
-// 点击条目可触发 onPickQuery 把 SQL 回填到 QueryEditor。
+// 点击条目可触发 onPickQuery,把 SQL 带到外部工作台或编辑器。
//
// 设计要点:
// - 独立 Modal,不依赖 Sidebar 内部布局(Sidebar.tsx 已经 9000+ 行,避免污染)
@@ -40,7 +40,19 @@ interface SlowQueryPanelProps {
onPickQuery?: (sql: string) => void
}
-export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
+interface SlowQueryPanelContentProps {
+ config: ConnectionConfig
+ dbName: string
+ onPickQuery?: (sql: string) => void
+ activeToken?: string | number | null
+}
+
+export function SlowQueryPanelContent({
+ config,
+ dbName,
+ onPickQuery,
+ activeToken,
+}: SlowQueryPanelContentProps) {
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState([])
const [error, setError] = useState(null)
@@ -65,10 +77,11 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
}, [config, dbName, sortBy])
useEffect(() => {
- if (open) {
- void reload()
+ if (activeToken === null || activeToken === undefined || activeToken === '') {
+ return
}
- }, [open, reload])
+ void reload()
+ }, [activeToken, reload])
const handleClear = useCallback(async () => {
const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName)
@@ -84,32 +97,15 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
(record: SlowQueryRecord) => {
if (record.sqlPreview && onPickQuery) {
onPickQuery(record.sqlPreview)
- onClose()
}
},
- [onPickQuery, onClose],
+ [onPickQuery],
)
const sorted = useMemo(() => records, [records]) // 后端已排序,前端不再排
return (
-
-
- 慢 SQL 历史
-
- {dbName || '(当前连接)'}
-
-
- }
- destroyOnClose
- >
+
0 && (
-
+
{sorted.map((r, idx) => (
handlePick(r)} />
))}
)}
+
+ )
+}
+
+export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
+ return (
+
+
+ 慢 SQL 历史
+
+ {dbName || '(当前连接)'}
+
+
+ }
+ destroyOnClose
+ >
+
+ {
+ onPickQuery?.(sql)
+ onClose()
+ }}
+ />
+
)
}
diff --git a/frontend/src/components/explain/SqlAnalysisWorkbench.tsx b/frontend/src/components/explain/SqlAnalysisWorkbench.tsx
new file mode 100644
index 0000000..c59ce43
--- /dev/null
+++ b/frontend/src/components/explain/SqlAnalysisWorkbench.tsx
@@ -0,0 +1,255 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
+import { Alert, Button, Input, Segmented, Typography, message } from 'antd'
+import { HistoryOutlined, SearchOutlined } from '@ant-design/icons'
+import { useStore } from '../../store'
+import type { ConnectionConfig, TabData } from '../../types'
+import { ExplainReportView } from './ExplainWorkbench'
+import { SlowQueryPanelContent } from './SlowQueryPanel'
+
+const { Title, Text } = Typography
+
+type SqlAnalysisViewKey = 'diagnose' | 'slow-query'
+
+const resolveRequestedView = (tab: TabData): SqlAnalysisViewKey =>
+ tab.sqlAnalysisView === 'slow-query' ? 'slow-query' : 'diagnose'
+
+const normalizeConnectionConfig = (connection: any): ConnectionConfig => ({
+ ...connection.config,
+ port: Number(connection.config.port),
+ password: connection.config.password || '',
+ database: connection.config.database || '',
+ useSSH: connection.config.useSSH || false,
+ ssh: connection.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
+})
+
+export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
+ const connections = useStore((state) => state.connections)
+ const connection = useMemo(
+ () => connections.find((item) => item.id === tab.connectionId) || null,
+ [connections, tab.connectionId],
+ )
+ const connectionConfig = useMemo(
+ () => (connection ? normalizeConnectionConfig(connection) : null),
+ [connection],
+ )
+ const dbName = String(tab.dbName || '').trim()
+ const [activeView, setActiveView] = useState
(() => resolveRequestedView(tab))
+ const [sqlDraft, setSqlDraft] = useState(() => String(tab.query || ''))
+ const [diagnoseRunKey, setDiagnoseRunKey] = useState(0)
+
+ useEffect(() => {
+ const nextView = resolveRequestedView(tab)
+ const nextSql = String(tab.query || '')
+ setActiveView(nextView)
+ if (nextSql) {
+ setSqlDraft(nextSql)
+ }
+ if (nextView === 'diagnose' && nextSql.trim()) {
+ setDiagnoseRunKey((previous) => previous + 1)
+ }
+ }, [tab.query, tab.sqlAnalysisRequestKey, tab.sqlAnalysisView])
+
+ const triggerDiagnose = useCallback(() => {
+ if (!sqlDraft.trim()) {
+ message.warning('请输入要诊断的 SQL')
+ return
+ }
+ setActiveView('diagnose')
+ setDiagnoseRunKey((previous) => previous + 1)
+ }, [sqlDraft])
+
+ const handlePickSlowQuery = useCallback((sql: string) => {
+ const nextSql = String(sql || '')
+ if (!nextSql.trim()) {
+ return
+ }
+ setSqlDraft(nextSql)
+ setActiveView('diagnose')
+ setDiagnoseRunKey((previous) => previous + 1)
+ }, [])
+
+ const slowQueryLoadKey = useMemo(
+ () =>
+ activeView === 'slow-query' && connectionConfig
+ ? `${tab.sqlAnalysisRequestKey || 'slow-query'}:${tab.connectionId}:${dbName}`
+ : null,
+ [activeView, connectionConfig, dbName, tab.connectionId, tab.sqlAnalysisRequestKey],
+ )
+
+ if (!connectionConfig) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ SQL 分析工作台
+
+
+ {connection?.name || tab.connectionId}
+ {dbName ? ` / ${dbName}` : ''}
+
+
+
setActiveView(value as SqlAnalysisViewKey)}
+ className="gn-sql-analysis-view-switcher"
+ options={[
+ {
+ value: 'slow-query',
+ label: (
+
+
+ 慢 SQL
+
+ ),
+ },
+ {
+ value: 'diagnose',
+ label: (
+
+
+ SQL 诊断
+
+ ),
+ },
+ ]}
+ />
+
+
+
+ {activeView === 'slow-query' ? (
+
+
+
+ ) : (
+
+
+
setSqlDraft(event.target.value)}
+ placeholder="输入要诊断的 SQL,或从慢 SQL 列表点击条目带入"
+ autoSize={{ minRows: 5, maxRows: 10 }}
+ />
+
+ 支持从慢 SQL 列表点击条目直接带入
+ } onClick={triggerDiagnose}>
+ 运行诊断
+
+
+
+
+ 0 ? diagnoseRunKey : null}
+ />
+
+
+ )}
+
+
+ )
+}
+
+const workbenchStyles = `
+ .gn-sql-analysis-workbench {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px;
+ overflow: hidden;
+ box-sizing: border-box;
+ }
+ .gn-sql-analysis-workbench-header {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+ .gn-sql-analysis-workbench-header-main {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ }
+ .gn-sql-analysis-view-switcher {
+ flex: 0 0 auto;
+ align-self: flex-start;
+ }
+ .gn-sql-analysis-view-switcher-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ min-width: 88px;
+ white-space: nowrap;
+ }
+ .gn-sql-analysis-view-switcher .ant-segmented-group {
+ display: inline-flex;
+ align-items: center;
+ }
+ .gn-sql-analysis-view-switcher .ant-segmented-item {
+ min-height: 30px;
+ }
+ .gn-sql-analysis-view-switcher .ant-segmented-item-label {
+ padding: 5px 12px;
+ font-size: 13px;
+ line-height: 20px;
+ }
+ .gn-sql-analysis-workbench-body {
+ flex: 1 1 auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .gn-sql-analysis-pane {
+ height: 100%;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ .gn-sql-analysis-editor-block {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 12px;
+ }
+ .gn-sql-analysis-editor-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+ .gn-sql-analysis-report-shell {
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+ }
+`
diff --git a/frontend/src/components/sidebar/SlowQueryRailButton.tsx b/frontend/src/components/sidebar/SlowQueryRailButton.tsx
index e383739..876a152 100644
--- a/frontend/src/components/sidebar/SlowQueryRailButton.tsx
+++ b/frontend/src/components/sidebar/SlowQueryRailButton.tsx
@@ -1,99 +1,104 @@
-import { lazy, Suspense, useMemo, useState } from 'react'
+import { useMemo } from 'react'
import { Tooltip } from 'antd'
import { HistoryOutlined } from '@ant-design/icons'
import { useStore } from '../../store'
-import type { ConnectionConfig } from '../../types'
-// lazy 加载避免 SlowQueryPanel(react-flow + dagre 约 200KB)进入主 bundle
-const SlowQueryPanel = lazy(() => import('../explain/SlowQueryPanel'))
+import { buildSqlAnalysisWorkbenchTab } from '../../utils/sqlAnalysisTab'
-// Sidebar 顶部的慢 SQL 历史入口。
+// Sidebar 底部的慢 SQL 工作台入口。
//
// 设计要点:
// - 完全独立组件,不依赖 Sidebar.tsx 内部 state(避免改 Sidebar Props)
-// - 自己从 store 读取 tabs/connections,解析当前激活 tab 的连接配置
-// - 通过 lazy import SlowQueryPanel(react-flow/dagre 不进入主 bundle)
+// - 自己从 store 读取 tabs/connections,定位当前激活 tab 的连接上下文
+// - 点击后打开/聚焦 SQL 分析工作台,并默认切到慢 SQL 视图
// - 没有激活的连接时按钮禁用,hover 给提示
//
-// 挂载位置:由调用方决定(App.tsx 把它放在 Sidebar 容器内)
+// 挂载位置:由调用方决定;当前用于 Sidebar 底部 footer。
interface SlowQueryRailButtonProps {
/** 自定义 className 用于外层定位 */
className?: string
/** 自定义 style(用于绝对定位到 Sidebar 角落) */
style?: React.CSSProperties
+ /** tooltip 位置 */
+ tooltipPlacement?:
+ | 'top'
+ | 'right'
+ | 'bottom'
+ | 'left'
+ | 'topLeft'
+ | 'topRight'
+ | 'bottomLeft'
+ | 'bottomRight'
+ | 'leftTop'
+ | 'leftBottom'
+ | 'rightTop'
+ | 'rightBottom'
}
-export default function SlowQueryRailButton({ className, style }: SlowQueryRailButtonProps) {
- const [open, setOpen] = useState(false)
+export default function SlowQueryRailButton({
+ className,
+ style,
+ tooltipPlacement = 'right',
+}: SlowQueryRailButtonProps) {
const tabs = useStore(s => s.tabs)
const activeTabId = useStore(s => s.activeTabId)
const connections = useStore(s => s.connections)
+ const addTab = useStore(s => s.addTab)
- // 解析当前激活 tab 的 ConnectionConfig
- const activeConfig = useMemo(() => {
- if (!activeTabId) return null
- const tab = tabs.find(t => t.id === activeTabId)
- if (!tab?.connectionId) return null
- const conn = connections.find(c => c.id === tab.connectionId)
- if (!conn) return null
- return {
- ...conn.config,
- port: Number(conn.config.port),
- password: conn.config.password || '',
- database: conn.config.database || '',
- useSSH: conn.config.useSSH || false,
- ssh: conn.config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' },
- } as ConnectionConfig
- }, [tabs, activeTabId, connections])
+ const activeTab = useMemo(
+ () => tabs.find(t => t.id === activeTabId) || null,
+ [activeTabId, tabs],
+ )
+ const hasActiveConnection = useMemo(
+ () =>
+ Boolean(
+ activeTab?.connectionId &&
+ connections.some(connection => connection.id === activeTab.connectionId),
+ ),
+ [activeTab, connections],
+ )
- const activeTab = tabs.find(t => t.id === activeTabId)
- const dbName = activeTab?.dbName || ''
-
- const buttonDisabled = !activeConfig
+ const buttonDisabled = !hasActiveConnection
const tooltipText = buttonDisabled
- ? '请先打开一个数据库连接的查询标签'
- : '查看当前连接的慢 SQL 历史(Ctrl+Shift+L)'
+ ? '请先打开一个数据库连接的标签页'
+ : '打开当前连接的 SQL 分析工作台'
return (
- <>
-
-
-
-
- {open && activeConfig && (
-
- setOpen(false)}
- config={activeConfig}
- dbName={dbName}
- />
-
- )}
- >
+
+
+
)
}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 40fd154..766cca2 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -436,6 +436,7 @@ export interface TabData {
| "query"
| "table"
| "design"
+ | "sql-analysis"
| "redis-keys"
| "redis-command"
| "redis-monitor"
@@ -479,6 +480,8 @@ export interface TabData {
tableExportInitialScope?: TableExportScope;
tableExportQueryByScope?: Partial>;
tableExportRowCountByScope?: Partial>;
+ sqlAnalysisView?: "diagnose" | "slow-query";
+ sqlAnalysisRequestKey?: string;
formatRestoreSnapshot?: {
query: string;
createdAt: number;
diff --git a/frontend/src/utils/sqlAnalysisTab.test.ts b/frontend/src/utils/sqlAnalysisTab.test.ts
new file mode 100644
index 0000000..9bd43ba
--- /dev/null
+++ b/frontend/src/utils/sqlAnalysisTab.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import { buildSqlAnalysisWorkbenchTab, resolveSqlAnalysisWorkbenchTabId } from './sqlAnalysisTab'
+
+describe('sqlAnalysisTab', () => {
+ it('builds a stable workbench tab per connection and database', () => {
+ expect(resolveSqlAnalysisWorkbenchTabId('conn-1', 'analytics')).toBe('sql-analysis-conn-1-analytics')
+ expect(resolveSqlAnalysisWorkbenchTabId('conn-1')).toBe('sql-analysis-conn-1-default')
+ })
+
+ it('keeps diagnose requests on the sql-analysis tab with optional seeded sql', () => {
+ const tab = buildSqlAnalysisWorkbenchTab({
+ connectionId: 'conn-1',
+ dbName: 'analytics',
+ query: 'select * from orders',
+ view: 'diagnose',
+ requestKey: 'diagnose-1',
+ })
+
+ expect(tab).toMatchObject({
+ id: 'sql-analysis-conn-1-analytics',
+ title: 'SQL 分析 · analytics',
+ type: 'sql-analysis',
+ connectionId: 'conn-1',
+ dbName: 'analytics',
+ query: 'select * from orders',
+ sqlAnalysisView: 'diagnose',
+ sqlAnalysisRequestKey: 'diagnose-1',
+ })
+ })
+
+ it('does not clear existing sql when opening the slow-query view without a seeded query', () => {
+ const tab = buildSqlAnalysisWorkbenchTab({
+ connectionId: 'conn-1',
+ view: 'slow-query',
+ requestKey: 'slow-1',
+ })
+
+ expect(tab.query).toBeUndefined()
+ expect(tab.sqlAnalysisView).toBe('slow-query')
+ expect(tab.sqlAnalysisRequestKey).toBe('slow-1')
+ })
+})
diff --git a/frontend/src/utils/sqlAnalysisTab.ts b/frontend/src/utils/sqlAnalysisTab.ts
new file mode 100644
index 0000000..dd74b86
--- /dev/null
+++ b/frontend/src/utils/sqlAnalysisTab.ts
@@ -0,0 +1,42 @@
+import type { TabData } from '../types'
+
+export type SqlAnalysisView = 'diagnose' | 'slow-query'
+
+type BuildSqlAnalysisWorkbenchTabInput = {
+ connectionId: string
+ dbName?: string
+ title?: string
+ query?: string
+ view?: SqlAnalysisView
+ requestKey?: string
+}
+
+export const resolveSqlAnalysisWorkbenchTabId = (
+ connectionId: string,
+ dbName?: string,
+): string => {
+ const normalizedConnectionId = String(connectionId || '').trim() || 'none'
+ const normalizedDbName = String(dbName || '').trim() || 'default'
+ return `sql-analysis-${normalizedConnectionId}-${normalizedDbName}`
+}
+
+export const buildSqlAnalysisWorkbenchTab = (
+ input: BuildSqlAnalysisWorkbenchTabInput,
+): TabData => {
+ const connectionId = String(input.connectionId || '').trim()
+ const dbName = String(input.dbName || '').trim()
+ const view = input.view === 'slow-query' ? 'slow-query' : 'diagnose'
+ const title = String(input.title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析')).trim()
+ const query = typeof input.query === 'string' ? input.query : ''
+
+ return {
+ id: resolveSqlAnalysisWorkbenchTabId(connectionId, dbName || undefined),
+ title: title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析'),
+ type: 'sql-analysis',
+ connectionId,
+ ...(dbName ? { dbName } : {}),
+ ...(query.trim() ? { query } : {}),
+ sqlAnalysisView: view,
+ sqlAnalysisRequestKey: input.requestKey || `${view}-${Date.now()}`,
+ }
+}
diff --git a/frontend/src/utils/tabDisplay.ts b/frontend/src/utils/tabDisplay.ts
index 3859496..1dcd40d 100644
--- a/frontend/src/utils/tabDisplay.ts
+++ b/frontend/src/utils/tabDisplay.ts
@@ -464,6 +464,7 @@ export const getTabDisplayKindLabel = (tab: TabData): string => {
if (tab.type === 'design') return 'DESIGN';
if (tab.type === 'table-overview') return 'DB';
if (tab.type === 'table-export') return 'EXPORT';
+ if (tab.type === 'sql-analysis') return 'ANALYZE';
if (tab.type.startsWith('redis')) return 'REDIS';
if (tab.type.startsWith('jvm')) return 'JVM';
if (tab.type === 'trigger') return 'TRG';
diff --git a/frontend/src/v2-theme.css b/frontend/src/v2-theme.css
index 837f951..9ae0397 100644
--- a/frontend/src/v2-theme.css
+++ b/frontend/src/v2-theme.css
@@ -2588,10 +2588,15 @@ body[data-ui-version="v2"] .gn-v2-sidebar-log-footer {
border-top: 0.5px solid var(--gn-br-1);
background: var(--gn-bg-panel-2);
flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 8px;
}
body[data-ui-version="v2"] .gn-v2-sidebar-log-button {
- width: 100%;
+ width: auto;
+ min-width: 0;
+ flex: 1 1 auto;
height: 28px;
border: 0;
border-radius: 8px;
@@ -2617,6 +2622,17 @@ body[data-ui-version="v2"] .gn-v2-sidebar-log-button small {
font-weight: 400;
}
+body[data-ui-version="v2"] .gn-v2-sidebar-slow-query-button {
+ width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ border-radius: 8px;
+}
+
+body[data-ui-version="v2"] .gn-v2-sidebar-slow-query-button:hover {
+ background: var(--gn-bg-hover) !important;
+}
+
body[data-ui-version="v2"] .gn-v2-empty-workbench {
min-height: 100%;
display: grid;
diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json
index 1542a10..585796f 100644
--- a/shared/i18n/de-DE.json
+++ b/shared/i18n/de-DE.json
@@ -90,6 +90,7 @@
"import_preview.result.failed_rows": "{{count}} Zeilen fehlgeschlagen",
"import_preview.result.error_logs": "Fehlerprotokolle:",
"log_panel.title": "SQL-Ausfuehrungslog",
+ "log_panel.short_title": "Logs",
"log_panel.description": "Zeichnet Ausfuehrungsstatus, Dauer und Fehler fuer schnelle Nachverfolgung auf.",
"log_panel.action.clear": "Logs leeren",
"log_panel.action.close": "Panel schliessen",
@@ -1576,6 +1577,7 @@
"tab_manager.kind_badge.event": "Ereignis",
"tab_manager.kind_badge.routine": "Funktion",
"tab_manager.kind_badge.table_export": "Export",
+ "tab_manager.kind_badge.sql_analysis": "Analyse",
"tab_manager.kind_badge.fallback": "Tab",
"tab_manager.empty.action.open_ai": "AI öffnen",
"tab_manager.empty.aria.start_workbench": "GoNavi-Startarbeitsbereich",
@@ -1610,6 +1612,7 @@
"tab_manager.hover.kind.routine": "Funktion / Prozedur",
"tab_manager.hover.kind.table": "Tabellendaten",
"tab_manager.hover.kind.table_export": "Export-Workbench",
+ "tab_manager.hover.kind.sql_analysis": "SQL-Analyse-Workbench",
"tab_manager.hover.kind.table_overview": "Tabellenübersicht",
"tab_manager.hover.kind.trigger": "Trigger",
"tab_manager.hover.kind.view": "Ansicht",
diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json
index 77a7ad2..7f4924c 100644
--- a/shared/i18n/en-US.json
+++ b/shared/i18n/en-US.json
@@ -90,6 +90,7 @@
"import_preview.result.failed_rows": "Failed {{count}} rows",
"import_preview.result.error_logs": "Error logs:",
"log_panel.title": "SQL execution log",
+ "log_panel.short_title": "Logs",
"log_panel.description": "Track execution status, duration, and errors for quick review.",
"log_panel.action.clear": "Clear logs",
"log_panel.action.close": "Close panel",
@@ -1584,6 +1585,7 @@
"tab_manager.kind_badge.event": "Event",
"tab_manager.kind_badge.routine": "Func",
"tab_manager.kind_badge.table_export": "Export",
+ "tab_manager.kind_badge.sql_analysis": "Analyze",
"tab_manager.kind_badge.fallback": "Tab",
"tab_manager.empty.action.open_ai": "Open AI",
"tab_manager.empty.aria.start_workbench": "GoNavi start workbench",
@@ -1618,6 +1620,7 @@
"tab_manager.hover.kind.routine": "Function / procedure",
"tab_manager.hover.kind.table": "Table data",
"tab_manager.hover.kind.table_export": "Export workbench",
+ "tab_manager.hover.kind.sql_analysis": "SQL analysis workbench",
"tab_manager.hover.kind.table_overview": "Table overview",
"tab_manager.hover.kind.trigger": "Trigger",
"tab_manager.hover.kind.view": "View",
diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json
index f8d2d27..003eab9 100644
--- a/shared/i18n/ja-JP.json
+++ b/shared/i18n/ja-JP.json
@@ -90,6 +90,7 @@
"import_preview.result.failed_rows": "{{count}} 行が失敗しました",
"import_preview.result.error_logs": "エラーログ:",
"log_panel.title": "SQL 実行ログ",
+ "log_panel.short_title": "ログ",
"log_panel.description": "実行状態、所要時間、エラー情報を記録してすばやく確認できます。",
"log_panel.action.clear": "ログをクリア",
"log_panel.action.close": "パネルを閉じる",
@@ -1576,6 +1577,7 @@
"tab_manager.kind_badge.event": "イベント",
"tab_manager.kind_badge.routine": "関数",
"tab_manager.kind_badge.table_export": "エクスポート",
+ "tab_manager.kind_badge.sql_analysis": "分析",
"tab_manager.kind_badge.fallback": "タブ",
"tab_manager.empty.action.open_ai": "AI を開く",
"tab_manager.empty.aria.start_workbench": "GoNavi 開始ワークベンチ",
@@ -1610,6 +1612,7 @@
"tab_manager.hover.kind.routine": "関数 / プロシージャ",
"tab_manager.hover.kind.table": "テーブルデータ",
"tab_manager.hover.kind.table_export": "エクスポートワークベンチ",
+ "tab_manager.hover.kind.sql_analysis": "SQL 分析ワークベンチ",
"tab_manager.hover.kind.table_overview": "テーブル概要",
"tab_manager.hover.kind.trigger": "トリガー",
"tab_manager.hover.kind.view": "ビュー",
diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json
index 8fbcf56..9308b71 100644
--- a/shared/i18n/ru-RU.json
+++ b/shared/i18n/ru-RU.json
@@ -90,6 +90,7 @@
"import_preview.result.failed_rows": "Строк с ошибками: {{count}}",
"import_preview.result.error_logs": "Журнал ошибок:",
"log_panel.title": "Журнал выполнения SQL",
+ "log_panel.short_title": "Логи",
"log_panel.description": "Фиксирует статус выполнения, длительность и ошибки для быстрого анализа.",
"log_panel.action.clear": "Очистить журнал",
"log_panel.action.close": "Закрыть панель",
@@ -1576,6 +1577,7 @@
"tab_manager.kind_badge.event": "Событие",
"tab_manager.kind_badge.routine": "Функция",
"tab_manager.kind_badge.table_export": "Экспорт",
+ "tab_manager.kind_badge.sql_analysis": "Анализ",
"tab_manager.kind_badge.fallback": "Вкладка",
"tab_manager.empty.action.open_ai": "Открыть AI",
"tab_manager.empty.aria.start_workbench": "Стартовая рабочая область GoNavi",
@@ -1610,6 +1612,7 @@
"tab_manager.hover.kind.routine": "Функция / процедура",
"tab_manager.hover.kind.table": "Данные таблицы",
"tab_manager.hover.kind.table_export": "Рабочая область экспорта",
+ "tab_manager.hover.kind.sql_analysis": "Рабочая область анализа SQL",
"tab_manager.hover.kind.table_overview": "Обзор таблицы",
"tab_manager.hover.kind.trigger": "Триггер",
"tab_manager.hover.kind.view": "Представление",
diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json
index 85ec7ea..d5b1deb 100644
--- a/shared/i18n/zh-CN.json
+++ b/shared/i18n/zh-CN.json
@@ -90,6 +90,7 @@
"import_preview.result.failed_rows": "失败 {{count}} 行",
"import_preview.result.error_logs": "错误日志:",
"log_panel.title": "SQL 执行日志",
+ "log_panel.short_title": "日志",
"log_panel.description": "记录执行状态、耗时与错误信息,便于快速回溯。",
"log_panel.action.clear": "清空日志",
"log_panel.action.close": "关闭面板",
@@ -1584,6 +1585,7 @@
"tab_manager.kind_badge.event": "事件",
"tab_manager.kind_badge.routine": "函数",
"tab_manager.kind_badge.table_export": "导出",
+ "tab_manager.kind_badge.sql_analysis": "分析",
"tab_manager.kind_badge.fallback": "标签",
"tab_manager.empty.action.open_ai": "打开 AI",
"tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台",
@@ -1618,6 +1620,7 @@
"tab_manager.hover.kind.routine": "函数 / 存储过程",
"tab_manager.hover.kind.table": "表数据",
"tab_manager.hover.kind.table_export": "导出工作台",
+ "tab_manager.hover.kind.sql_analysis": "SQL 分析工作台",
"tab_manager.hover.kind.table_overview": "表概览",
"tab_manager.hover.kind.trigger": "触发器",
"tab_manager.hover.kind.view": "视图",
diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json
index fe41951..24e42c1 100644
--- a/shared/i18n/zh-TW.json
+++ b/shared/i18n/zh-TW.json
@@ -90,6 +90,7 @@
"import_preview.result.failed_rows": "失敗 {{count}} 列",
"import_preview.result.error_logs": "錯誤記錄:",
"log_panel.title": "SQL 執行記錄",
+ "log_panel.short_title": "日誌",
"log_panel.description": "記錄執行狀態、耗時與錯誤資訊,方便快速回溯。",
"log_panel.action.clear": "清空記錄",
"log_panel.action.close": "關閉面板",
@@ -1576,6 +1577,7 @@
"tab_manager.kind_badge.event": "事件",
"tab_manager.kind_badge.routine": "函式",
"tab_manager.kind_badge.table_export": "匯出",
+ "tab_manager.kind_badge.sql_analysis": "分析",
"tab_manager.kind_badge.fallback": "標籤",
"tab_manager.empty.action.open_ai": "開啟 AI",
"tab_manager.empty.aria.start_workbench": "GoNavi 起始工作台",
@@ -1610,6 +1612,7 @@
"tab_manager.hover.kind.routine": "函數 / 程序",
"tab_manager.hover.kind.table": "表資料",
"tab_manager.hover.kind.table_export": "匯出工作台",
+ "tab_manager.hover.kind.sql_analysis": "SQL 分析工作台",
"tab_manager.hover.kind.table_overview": "表概覽",
"tab_manager.hover.kind.trigger": "觸發器",
"tab_manager.hover.kind.view": "視圖",