feat(query-editor): 收敛 SQL 分析工作台与结果区日志体验

- 新增 SQL 分析工作台,统一承载慢 SQL 和 SQL 诊断视图
- 将 SQL 执行日志收进结果区首个日志标签并在失败时展示错误摘要
- 调整侧边栏入口、标签展示、多语言文案与定向前端测试覆盖
This commit is contained in:
Syngnat
2026-06-20 14:09:58 +08:00
parent 04019135a0
commit c8c8497a2f
26 changed files with 1539 additions and 539 deletions

View File

@@ -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}
/>
</div>
{/* 慢 SQL 历史入口:浮动在 Sidebar 右下角,独立组件不依赖 Sidebar 内部 state */}
<SlowQueryRailButton
style={{
position: 'absolute',
right: 8,
bottom: isV2Ui ? 8 : 66,
zIndex: 10,
background: 'var(--gn-card-bg, rgba(255,255,255,0.9))',
borderRadius: 6,
boxShadow: '0 1px 4px rgba(0,0,0,0.1)',
}}
/>
{!connectionWorkbenchState.ready && (
<div
style={{
@@ -3138,10 +3135,10 @@ function App() {
</div>
)}
</div>
{isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={handleCloseLogPanel}
{!isV2Ui && isLogPanelOpen && (
<LogPanel
height={logPanelHeight}
onClose={handleCloseLogPanel}
onResizeStart={handleLogResizeStart}
/>
)}

View File

@@ -82,15 +82,16 @@ vi.mock("@ant-design/icons", async () => {
ClearOutlined: Icon,
CloseOutlined: Icon,
ClockCircleOutlined: Icon,
RobotOutlined: Icon,
};
});
const renderLogPanel = () => {
const renderLogPanel = (props: Partial<React.ComponentProps<typeof LogPanel>> = {}) => {
let renderer!: ReactTestRenderer;
act(() => {
renderer = create(
<I18nProvider preference="en-US" onPreferenceChange={() => undefined}>
<LogPanel height={260} onClose={vi.fn()} onResizeStart={vi.fn()} />
<LogPanel height={260} onClose={vi.fn()} onResizeStart={vi.fn()} {...props} />
</I18nProvider>,
);
});
@@ -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");

View File

@@ -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<LogPanelProps> = ({ height, onClose, onResizeStart }) => {
const LogPanel: React.FC<LogPanelProps> = ({
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<LogPanelProps> = ({ 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<LogPanelProps> = ({ height, onClose, onResizeStart }) =
}
];
const logTable = (
<div
className="log-panel-scroll"
style={{
flex: 1,
overflow: 'auto',
padding: isEmbedded ? '0 12px 12px' : '8px 10px 10px',
}}
>
{sqlLogs.length === 0 ? (
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span style={{ color: panelMutedTextColor }}>{t('log_panel.empty')}</span>}
/>
</div>
) : (
<Table
className="log-panel-table"
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
/>
)}
</div>
);
const sharedStyles = (
<style>{`
.log-panel-scroll {
scrollbar-width: thin;
scrollbar-color: ${logScrollbarThumb} transparent;
}
.log-panel-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.log-panel-scroll::-webkit-scrollbar-track,
.log-panel-scroll::-webkit-scrollbar-corner {
background: transparent;
}
.log-panel-scroll::-webkit-scrollbar-thumb {
background: ${logScrollbarThumb};
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.log-panel-scroll::-webkit-scrollbar-thumb:hover {
background: ${logScrollbarThumbHover};
background-clip: padding-box;
}
.log-panel-table .ant-table,
.log-panel-table .ant-table-container,
.log-panel-table .ant-table-tbody > tr > td {
background: transparent !important;
}
.log-panel-table .ant-table-tbody > tr > td {
padding: 8px 10px !important;
border-bottom: 1px solid ${panelDividerColor} !important;
}
.log-panel-table .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}
.log-panel-table .ant-table-row:hover > td {
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
}
`}</style>
);
if (isEmbedded) {
return (
<div
style={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div
style={{
flex: '0 0 auto',
padding: '8px 12px',
borderBottom: `1px solid ${panelDividerColor}`,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
minHeight: 40,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div
style={{
width: 26,
height: 26,
borderRadius: 8,
display: 'grid',
placeItems: 'center',
background: darkMode
? `rgba(255,214,102,${Math.max(0.08, Math.min(0.14, opacity * 0.14))})`
: `rgba(24,144,255,${Math.max(0.06, Math.min(0.12, opacity * 0.12))})`,
color: panelAccentColor,
flexShrink: 0,
}}
>
<BugOutlined />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}>
{t('log_panel.description')}
</div>
<div style={{ fontSize: 11, color: panelMutedTextColor }}>
{logCountLabel}
</div>
</div>
</div>
<Tooltip title={t('log_panel.action.clear')}>
<Button
type="text"
size="small"
icon={<ClearOutlined />}
onClick={clearSqlLogs}
style={{ color: panelMutedTextColor }}
/>
</Tooltip>
</div>
{executionError && (
<div style={{ padding: '12px 12px 0' }}>
<div style={{
padding: 14,
borderRadius: 8,
border: `1px solid ${darkMode ? '#5c2020' : '#ffccc7'}`,
background: darkMode ? '#2d1a1a' : '#fff2f0',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<div style={{ color: '#ff7875', fontWeight: 700, fontSize: 13, display: 'flex', alignItems: 'center', gap: 8 }}>
<CloseOutlined />
<span>{t('query_editor.result.execution_failed')}</span>
</div>
<div
className="log-panel-scroll"
style={{
paddingRight: 4,
maxHeight: 220,
overflow: 'auto',
color: darkMode ? '#ffa39e' : '#cf1322',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 12px)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
lineHeight: 1.55,
}}
>
{executionError}
</div>
{onDiagnoseExecutionError && (
<div>
<Button
type="primary"
icon={<RobotOutlined />}
onClick={onDiagnoseExecutionError}
style={{ background: '#818cf8', borderColor: '#818cf8', boxShadow: '0 2px 0 rgba(129, 140, 248, 0.2)' }}
>
{t('query_editor.result.ai_diagnose')}
</Button>
</div>
)}
</div>
</div>
)}
{logTable}
{sharedStyles}
</div>
);
}
return (
<div style={{
height,
@@ -103,18 +298,20 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
zIndex: 100
}}>
{/* Resize Handle */}
<div
onMouseDown={onResizeStart}
style={{
position: 'absolute',
top: -4,
left: 0,
right: 0,
height: 8,
cursor: 'row-resize',
zIndex: 10
}}
/>
{onResizeStart && (
<div
onMouseDown={onResizeStart}
style={{
position: 'absolute',
top: -4,
left: 0,
right: 0,
height: 8,
cursor: 'row-resize',
zIndex: 10
}}
/>
)}
{/* Toolbar */}
<div style={{
@@ -139,72 +336,16 @@ const LogPanel: React.FC<LogPanelProps> = ({ height, onClose, onResizeStart }) =
<Tooltip title={t('log_panel.action.clear')}>
<Button type="text" size="small" icon={<ClearOutlined />} onClick={clearSqlLogs} style={{ color: panelMutedTextColor }} />
</Tooltip>
<Tooltip title={t('log_panel.action.close')}>
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
</Tooltip>
{onClose && (
<Tooltip title={t('log_panel.action.close')}>
<Button type="text" size="small" icon={<CloseOutlined />} onClick={onClose} style={{ color: panelMutedTextColor }} />
</Tooltip>
)}
</div>
</div>
{/* List */}
<div className="log-panel-scroll" style={{ flex: 1, overflow: 'auto', padding: '8px 10px 10px' }}>
{sqlLogs.length === 0 ? (
<div style={{ height: '100%', minHeight: 160, display: 'grid', placeItems: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={<span style={{ color: panelMutedTextColor }}>{t('log_panel.empty')}</span>}
/>
</div>
) : (
<Table
className="log-panel-table"
dataSource={sqlLogs}
columns={columns}
size="small"
pagination={false}
rowKey="id"
showHeader={false}
/>
)}
</div>
<style>{`
.log-panel-scroll {
scrollbar-width: thin;
scrollbar-color: ${logScrollbarThumb} transparent;
}
.log-panel-scroll::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.log-panel-scroll::-webkit-scrollbar-track,
.log-panel-scroll::-webkit-scrollbar-corner {
background: transparent;
}
.log-panel-scroll::-webkit-scrollbar-thumb {
background: ${logScrollbarThumb};
border-radius: 8px;
border: 2px solid transparent;
background-clip: padding-box;
}
.log-panel-scroll::-webkit-scrollbar-thumb:hover {
background: ${logScrollbarThumbHover};
background-clip: padding-box;
}
.log-panel-table .ant-table,
.log-panel-table .ant-table-container,
.log-panel-table .ant-table-tbody > tr > td {
background: transparent !important;
}
.log-panel-table .ant-table-tbody > tr > td {
padding: 8px 10px !important;
border-bottom: 1px solid ${panelDividerColor} !important;
}
.log-panel-table .ant-table-tbody > tr:last-child > td {
border-bottom: none !important;
}
.log-panel-table .ant-table-row:hover > td {
background: ${darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)'} !important;
}
`}</style>
{logTable}
{sharedStyles}
</div>
);
};

View File

@@ -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 }) => (
<div data-log-panel={variant}>
SQL
{executionError ? ` ${executionError}` : ''}
</div>
),
}));
vi.mock('@ant-design/icons', () => {
const Icon = () => <span />;
return {
BugOutlined: Icon,
ClearOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -377,10 +397,30 @@ vi.mock('antd', () => {
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
<div>
{dataSource.map((record) => (
<div key={record.id}>
{columns.map((column) => (
<div key={column.dataIndex || column.title}>
{column.render
? column.render(record[column.dataIndex], record)
: record[column.dataIndex]}
</div>
))}
</div>
))}
</div>
);
const Empty = ({ description }: { description?: React.ReactNode }) => <div>{description}</div>;
(Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
return {
Button,
Space,
Table,
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
Empty,
message: messageApi,
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
<section>
@@ -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(<QueryEditor tab={createTab()} />);
renderer = create(
<I18nProvider preference="zh-CN" onPreferenceChange={() => undefined}>
<QueryEditor tab={createTab()} />
</I18nProvider>,
);
});
expect(textContent(renderer.toJSON())).not.toContain('等待执行 SQL');
@@ -678,7 +724,11 @@ describe('QueryEditor external SQL save', () => {
let renderer!: ReactTestRenderer;
await act(async () => {
renderer = create(<QueryEditor tab={createTab()} />);
renderer = create(
<I18nProvider preference="zh-CN" onPreferenceChange={() => undefined}>
<QueryEditor tab={createTab()} />
</I18nProvider>,
);
});
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<string, ((event?: any) => 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(<QueryEditor tab={createTab()} />);
});
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(<QueryEditor tab={createTab({ query: 'select 1;' })} />);
});
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;

View File

@@ -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 = () => <span />;
return {
BugOutlined: Icon,
ClearOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -377,10 +387,30 @@ vi.mock('antd', () => {
const Form: any = ({ children }: any) => <form>{children}</form>;
Form.Item = ({ children }: any) => <>{children}</>;
Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
const Table = ({ dataSource, columns }: { dataSource: any[]; columns: any[] }) => (
<div>
{dataSource.map((record) => (
<div key={record.id}>
{columns.map((column) => (
<div key={column.dataIndex || column.title}>
{column.render
? column.render(record[column.dataIndex], record)
: record[column.dataIndex]}
</div>
))}
</div>
))}
</div>
);
const Empty = ({ description }: { description?: React.ReactNode }) => <div>{description}</div>;
(Empty as any).PRESENTED_IMAGE_SIMPLE = 'simple';
return {
Button,
Space,
Table,
Tag: ({ children }: { children?: React.ReactNode }) => <span>{children}</span>,
Empty,
message: messageApi,
Modal: ({ children, open, onOk, okText = '确认' }: any) => (open ? (
<section>
@@ -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('<LogPanel');
expect(panelSource).toContain('variant="embedded"');
expect(panelSource).toContain('executionError={executionError}');
expect(panelSource).toContain("t('log_panel.short_title')");
expect(panelSource).toContain('[logTabItem, ...resultTabItems]');
expect(editorSource).toContain("window.addEventListener('gonavi:show-sql-execution-log'");
expect(editorSource).toContain('setActiveResultKey(QUERY_EDITOR_SQL_LOG_TAB_KEY)');
});
it('keeps the v2 query editor toolbar grouped and compact', () => {
const source = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8');
const toolbarSource = readFileSync(new URL('./QueryEditorToolbar.tsx', import.meta.url), 'utf8');

View File

@@ -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('<ExplainWorkbench')
expect(source).not.toContain('<SlowQueryPanel')
})
it('opens the same sql-analysis workbench from the sidebar slow-query button', () => {
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('<Tabs')
})
it('uses a compact segmented switcher inside the explain report view', () => {
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('<Tabs')
})
})

View File

@@ -1,5 +1,5 @@
import Modal from './common/ResizableDraggableModal';
import React, { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import Editor, { type OnMount } from './MonacoEditor';
import { message, Input, Form, MenuProps } from 'antd';
import { format } from 'sql-formatter';
@@ -31,11 +31,8 @@ import { splitSidebarQualifiedName } from '../utils/sidebarLocate';
import { buildMySQLCompatibleViewMetadataSqls, isSidebarViewTableType, normalizeSidebarViewName } from '../utils/sidebarMetadata';
import { SIDEBAR_SQL_EDITOR_DRAG_MIME, decodeSidebarSqlEditorDragPayload, hasSidebarSqlEditorDragPayload } from '../utils/sidebarSqlDrag';
import { resolveUniqueKeyGroupsFromIndexes } from './dataGridCopyInsert';
// SQL 诊断工作台lazy 加载避免 reactflow/dagre 进入主 bundle约 130KB gzipped 独立 chunk
const ExplainWorkbench = lazy(() => 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<string>(tab.connectionId);
const [currentDb, setCurrentDb] = useState<string>(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
)}
</span>
),
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
)}
</span>
),
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
</Form>
</Modal>
{/* SQL 诊断工作台Ctrl+Shift+D 触发lazy 加载避免 reactflow 进入主 bundle */}
<Suspense fallback={null}>
{explainOpen && explainConfig && (
<ExplainWorkbench
open={explainOpen}
onClose={() => setExplainOpen(false)}
config={explainConfig}
dbName={currentDb}
sql={query}
/>
)}
</Suspense>
{/* 慢 SQL 历史Ctrl+Shift+H 触发 */}
<Suspense fallback={null}>
{slowQueryOpen && explainConfig && (
<SlowQueryPanel
open={slowQueryOpen}
onClose={() => setSlowQueryOpen(false)}
config={explainConfig}
dbName={currentDb}
onPickQuery={(sql) => setQuery(sql)}
/>
)}
</Suspense>
</div>
);
};

View File

@@ -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<QueryEditorResultsPanelProps> = ({
activeResultKey,
loading,
executionError,
sqlLogCount,
darkMode,
isV2Ui,
currentDb,
@@ -76,44 +81,253 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
}) => {
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 = (
<Tooltip title={hideTooltipTitle}>
<Button
className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined}
icon={<EyeInvisibleOutlined />}
onClick={onHide}
>
<span>{t('query_editor.results_panel.action.hide')}</span>
{isV2Ui && toggleShortcutLabel && (
<span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>
)}
</Button>
</Tooltip>
);
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: (
<Dropdown
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
trigger={['contextMenu']}
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
>
<div
className="query-result-tab-label"
onContextMenu={(event) => {
event.preventDefault();
}}
>
<Tooltip title={rs.sql}>
<span className="query-result-tab-text">
{rs.resultType === 'message'
? t('query_editor.results_panel.tab.message', { index: idx + 1 })
: t('query_editor.results_panel.tab.result', { index: idx + 1 })}
</span>
</Tooltip>
{(() => {
if (rs.resultType === 'message') {
return <span className="query-result-tab-count">i</span>;
}
if (isAffectedRowsResult(rs)) {
return <span className="query-result-tab-count"></span>;
}
if (!Array.isArray(rs.rows)) {
return null;
}
return <span className="query-result-tab-count">{rs.rows.length}</span>;
})()}
<Tooltip title={t('query_editor.result.close')}>
<span
className="query-result-tab-close"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCloseResult(rs.key);
}}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
</Tooltip>
</div>
</Dropdown>
),
children: (() => {
if (rs.resultType === 'message') {
return (
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
flex: 1, minHeight: 0, display: 'flex', justifyContent: 'center',
flexDirection: 'column', gap: 12, padding: 24, color: '#666', userSelect: 'text',
overflow: 'auto',
}}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('query_editor.results_panel.message.title')}</span>
<div style={{
padding: 16,
borderRadius: 8,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 13px)',
}}>
{(rs.messages || []).join('\n')}
</div>
</div>
);
}
if (isAffectedRowsResult(rs)) {
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
return (
<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',
}}>
<span style={{ fontSize: 36, color: '#52c41a' }}></span>
<span style={{ fontSize: 14, fontWeight: 500 }}>{t('query_editor.result.execution_success')}</span>
<span style={{ fontSize: 13, color: '#999' }}>{t('query_editor.result.affected_rows', { count: affected })}</span>
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
<div style={{
marginTop: 8,
maxWidth: 720,
padding: 12,
borderRadius: 8,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 12px)',
}}>
{rs.messages.join('\n')}
</div>
)}
</div>
);
}
return (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
<div style={{
flex: '0 0 auto',
margin: '8px 8px 0',
padding: '10px 12px',
borderRadius: 8,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 12px)',
color: darkMode ? '#d4d4d4' : '#666',
}}>
{rs.messages.join('\n')}
</div>
)}
<DataGrid
data={rs.rows}
columnNames={rs.columns}
loading={loading || rs.page?.loading === true}
tableName={rs.tableName}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
resultExportAllSql={rs.page?.exportAllSql}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
showRowNumberColumn={rs.showRowNumberColumn}
onReload={() => {
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}
/>
</div>
);
})(),
}));
const resultTabItems = buildResultTabItems();
const logTabItem = shouldShowSqlLogTab
? {
key: QUERY_EDITOR_SQL_LOG_TAB_KEY,
label: (
<Tooltip title={t('log_panel.title')}>
<div className="query-result-tab-label">
<BugOutlined style={{ fontSize: 12 }} />
<span className="query-result-tab-text">{t('log_panel.short_title')}</span>
<span className="query-result-tab-count">{logTabCountLabel}</span>
</div>
</Tooltip>
),
children: (
<LogPanel
variant="embedded"
executionError={executionError}
onDiagnoseExecutionError={executionError ? onDiagnoseExecutionError : undefined}
/>
),
}
: 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 = (
<Tooltip title={hideTooltipTitle}>
@@ -151,21 +365,6 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
}
: undefined;
const toolbarHideButton = (
<Tooltip title={hideTooltipTitle}>
<Button
className={isV2Ui ? 'gn-v2-query-result-toolbar-hide' : undefined}
icon={<EyeInvisibleOutlined />}
onClick={onHide}
>
<span>{t('query_editor.results_panel.action.hide')}</span>
{isV2Ui && toggleShortcutLabel && (
<span className="gn-v2-toolbar-kbd">{toggleShortcutLabel}</span>
)}
</Button>
</Tooltip>
);
return (
<>
<style>{`
@@ -332,7 +531,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
className={isV2Ui ? 'gn-v2-query-results' : undefined}
style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column' }}
>
{resultSets.length > 0 ? (
{tabItems.length > 0 ? (
<Tabs
className="query-result-tabs"
activeKey={resolvedActiveResultKey}
@@ -340,160 +539,7 @@ const QueryEditorResultsPanel: React.FC<QueryEditorResultsPanelProps> = ({
animated={false}
style={{ flex: 1, minHeight: 0 }}
tabBarExtraContent={tabsExtraContent}
items={resultSets.map((rs, idx) => ({
key: rs.key,
label: (
<Dropdown
menu={{ items: buildResultTabMenuItems(rs.key, idx) }}
trigger={['contextMenu']}
rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}
>
<div
className="query-result-tab-label"
onContextMenu={(event) => {
event.preventDefault();
}}
>
<Tooltip title={rs.sql}>
<span className="query-result-tab-text">
{rs.resultType === 'message'
? t('query_editor.results_panel.tab.message', { index: idx + 1 })
: t('query_editor.results_panel.tab.result', { index: idx + 1 })}
</span>
</Tooltip>
{(() => {
if (rs.resultType === 'message') {
return <span className="query-result-tab-count">i</span>;
}
if (isAffectedRowsResult(rs)) {
return <span className="query-result-tab-count"></span>;
}
if (!Array.isArray(rs.rows)) {
return null;
}
return <span className="query-result-tab-count">{rs.rows.length}</span>;
})()}
<Tooltip title={t('query_editor.result.close')}>
<span
className="query-result-tab-close"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onCloseResult(rs.key);
}}
>
<CloseOutlined style={{ fontSize: 12 }} />
</span>
</Tooltip>
</div>
</Dropdown>
),
children: (() => {
if (rs.resultType === 'message') {
return (
<div className={isV2Ui ? 'gn-v2-query-success' : undefined} style={{
flex: 1, minHeight: 0, display: 'flex', justifyContent: 'center',
flexDirection: 'column', gap: 12, padding: 24, color: '#666', userSelect: 'text',
overflow: 'auto',
}}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('query_editor.results_panel.message.title')}</span>
<div style={{
padding: 16,
borderRadius: 8,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 13px)',
}}>
{(rs.messages || []).join('\n')}
</div>
</div>
);
}
if (isAffectedRowsResult(rs)) {
const affected = Number(rs.rows[0]?.affectedRows ?? 0);
return (
<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',
}}>
<span style={{ fontSize: 36, color: '#52c41a' }}></span>
<span style={{ fontSize: 14, fontWeight: 500 }}>{t('query_editor.result.execution_success')}</span>
<span style={{ fontSize: 13, color: '#999' }}>{t('query_editor.result.affected_rows', { count: affected })}</span>
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
<div style={{
marginTop: 8,
maxWidth: 720,
padding: 12,
borderRadius: 8,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 12px)',
}}>
{rs.messages.join('\n')}
</div>
)}
</div>
);
}
return (
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
{Array.isArray(rs.messages) && rs.messages.length > 0 && (
<div style={{
flex: '0 0 auto',
margin: '8px 8px 0',
padding: '10px 12px',
borderRadius: 8,
border: darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.08)',
background: darkMode ? 'rgba(255,255,255,0.03)' : '#fff',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'var(--gn-font-mono)',
fontSize: 'var(--gn-font-size-mono, 12px)',
color: darkMode ? '#d4d4d4' : '#666',
}}>
{rs.messages.join('\n')}
</div>
)}
<DataGrid
data={rs.rows}
columnNames={rs.columns}
loading={loading || rs.page?.loading === true}
tableName={rs.tableName}
exportScope="queryResult"
resultSql={rs.exportSql || rs.sql}
resultExportAllSql={rs.page?.exportAllSql}
dbName={currentDb}
connectionId={currentConnectionId}
pkColumns={rs.pkColumns}
editLocator={rs.editLocator}
showRowNumberColumn={rs.showRowNumberColumn}
onReload={() => {
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={resolvedActiveResultKey === rs.key ? toolbarHideButton : null}
/>
</div>
);
})(),
}))}
items={tabItems}
/>
) : executionError ? (
<>

View File

@@ -1,5 +1,6 @@
import SidebarConnectionRail from './sidebar/SidebarConnectionRail';
import SidebarSearchPanel, { type SidebarSearchPanelProps } from './sidebar/SidebarSearchPanel';
import SlowQueryRailButton from './sidebar/SlowQueryRailButton';
import { buildSidebarLegacyNodeMenuItems } from './sidebar/sidebarLegacyNodeMenu';
import {
getMetadataDialect,
@@ -2782,6 +2783,10 @@ const Sidebar: React.FC<{
<span>SQL </span>
<small>{sqlLogCount.toLocaleString()}</small>
</button>
<SlowQueryRailButton
className="gn-v2-sidebar-slow-query-button"
tooltipPlacement="top"
/>
</div>
)}
</div>

View File

@@ -123,6 +123,7 @@ describe('TabManager hover info', () => {
'view',
'event',
'routine',
'sql_analysis',
'fallback',
].forEach((name) => {
expect(getTabKindLabelSource).toContain(`t('tab_manager.kind_badge.${name}')`);

View File

@@ -24,6 +24,7 @@ import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
import SqlAnalysisWorkbench from './explain/SqlAnalysisWorkbench';
import type { TabData } from '../types';
import { t } from '../i18n';
import {
@@ -49,6 +50,7 @@ const getTabKindLabel = (tab: TabData): string => {
if (tab.type === 'design') return t('tab_manager.kind_badge.design');
if (tab.type === 'table-overview') return t('tab_manager.kind_badge.table_overview');
if (tab.type === 'table-export') return t('tab_manager.kind_badge.table_export');
if (tab.type === 'sql-analysis') return t('tab_manager.kind_badge.sql_analysis');
if (tab.type.startsWith('redis')) return t('tab_manager.kind_badge.redis');
if (tab.type.startsWith('jvm')) return t('tab_manager.kind_badge.jvm');
if (tab.type === 'trigger') return t('tab_manager.kind_badge.trigger');
@@ -70,6 +72,7 @@ const getTabKindTooltipLabel = (tab: TabData): string => {
if (tab.type === 'design') return t('tab_manager.hover.kind.design');
if (tab.type === 'table-overview') return t('tab_manager.hover.kind.table_overview');
if (tab.type === 'table-export') return t('tab_manager.hover.kind.table_export');
if (tab.type === 'sql-analysis') return t('tab_manager.hover.kind.sql_analysis');
if (tab.type === 'redis-keys') return t('tab_manager.hover.kind.redis_keys');
if (tab.type === 'redis-command') return t('tab_manager.hover.kind.redis_command');
if (tab.type === 'redis-monitor') return t('tab_manager.hover.kind.redis_monitor');
@@ -97,6 +100,7 @@ const getTabObjectLabel = (tab: TabData): string => {
if (tab.triggerName) return tab.triggerName;
if (tab.resourcePath) return tab.resourcePath;
if (tab.filePath) return tab.filePath;
if (tab.type === 'sql-analysis') return tab.title;
if (tab.type.startsWith('redis')) return `db${tab.redisDB ?? 0}`;
return '';
};
@@ -414,6 +418,9 @@ const TabContent: React.FC<{ tab: TabData; isActive: boolean }> = React.memo(({
if (tab.type === 'table-export') {
return <TableExportWorkbench tab={tab} />;
}
if (tab.type === 'sql-analysis') {
return <SqlAnalysisWorkbench tab={tab} />;
}
if (tab.type === 'jvm-overview') {
return <JVMOverview tab={tab} />;
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Modal, Spin, Tabs, Typography } from 'antd'
import { ApartmentOutlined, CodeOutlined } from '@ant-design/icons'
import { Empty, Modal, Segmented, Spin, Typography } from 'antd'
import { DiagnoseQuery } from '../../../wailsjs/go/app/App'
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
import type { ConnectionConfig } from '../../types'
@@ -31,11 +32,20 @@ interface ExplainWorkbenchProps {
sql: string
}
export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
interface ExplainReportViewProps {
config: ConnectionConfig
dbName: string
sql: string
runKey?: string | number | null
}
export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReportViewProps) {
const [loading, setLoading] = useState(false)
const [report, setReport] = useState<DiagnoseReport | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [activeView, setActiveView] = useState<'plan' | 'raw'>('plan')
const hasRequestedRun = runKey !== null && runKey !== undefined && runKey !== ''
const runDiagnose = useCallback(async () => {
if (!sql.trim()) {
@@ -62,10 +72,17 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
}, [config, dbName, sql])
useEffect(() => {
if (open) {
void runDiagnose()
if (!hasRequestedRun) {
return
}
}, [open, runDiagnose])
void runDiagnose()
}, [hasRequestedRun, runDiagnose, runKey])
useEffect(() => {
if (report) {
setActiveView('plan')
}
}, [report])
const selectedNode = useMemo<ExplainNode | undefined>(() => {
if (!report || !selectedNodeId) return undefined
@@ -78,6 +95,106 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
}
}, [])
return (
<div className="gn-explain-report-view">
<style>{reportViewStyles}</style>
{loading && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
{error}
</Paragraph>
)}
{!loading && !error && !report && !hasRequestedRun && (
<Empty description="输入 SQL 后运行诊断" style={{ padding: '48px 0' }} />
)}
{!loading && !error && report && (
<div className="gn-explain-report-shell">
<div className="gn-explain-report-switcher-row">
<Segmented
value={activeView}
onChange={(value) => setActiveView(value as 'plan' | 'raw')}
className="gn-explain-report-switcher"
options={[
{
value: 'plan',
label: (
<span className="gn-explain-report-switcher-label">
<ApartmentOutlined />
<span></span>
</span>
),
},
{
value: 'raw',
label: (
<span className="gn-explain-report-switcher-label">
<CodeOutlined />
<span></span>
</span>
),
},
]}
/>
<Text type="secondary" className="gn-explain-report-switcher-meta">
{report.plan.nodes.length}
<span className="gn-explain-report-switcher-meta-separator">/</span>
{report.plan.rawFormat}
</Text>
</div>
<div className="gn-explain-report-content">
{activeView === 'plan' ? (
<div className="gn-explain-plan-view">
<div className="gn-explain-plan-graph">
<ExplainGraph
nodes={report.plan.nodes}
edges={report.plan.edges ?? []}
selectedNodeId={selectedNodeId ?? undefined}
onSelectNode={setSelectedNodeId}
/>
</div>
<div className="gn-explain-plan-sidebar">
<ExplainSidebar
stats={report.plan.stats}
warnings={report.plan.warnings}
suggestions={report.suggestions ?? []}
selectedNode={selectedNode}
onSelectSuggestion={handleSelectSuggestion}
/>
</div>
</div>
) : (
<pre
style={{
height: '100%',
margin: 0,
overflow: 'auto',
background: 'var(--gn-code-bg, #f1f3f5)',
padding: 12,
borderRadius: 4,
fontSize: 12,
fontFamily: 'ui-monospace, Consolas, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
boxSizing: 'border-box',
}}
>
{report.plan.rawPayload || '(无原文)'}
</pre>
)}
</div>
</div>
)}
</div>
)
}
export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
return (
<Modal
open={open}
@@ -88,71 +205,96 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
title={<Title level={5} style={{ margin: 0 }}>SQL </Title>}
destroyOnClose
>
<div style={{ minHeight: 480 }}>
{loading && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
{error}
</Paragraph>
)}
{!loading && !error && report && (
<Tabs
items={[
{
key: 'plan',
label: `执行计划(${report.plan.nodes.length} 节点)`,
children: (
<div style={{ display: 'flex', gap: 12, height: '70vh', minHeight: 400 }}>
<div style={{ flex: 1, minWidth: 400, position: 'relative' }}>
<ExplainGraph
nodes={report.plan.nodes}
edges={report.plan.edges ?? []}
selectedNodeId={selectedNodeId ?? undefined}
onSelectNode={setSelectedNodeId}
/>
</div>
<div style={{ width: 320, flexShrink: 0, overflowY: 'auto', maxHeight: '70vh' }}>
<ExplainSidebar
stats={report.plan.stats}
warnings={report.plan.warnings}
suggestions={report.suggestions ?? []}
selectedNode={selectedNode}
onSelectSuggestion={handleSelectSuggestion}
/>
</div>
</div>
),
},
{
key: 'raw',
label: `原文(${report.plan.rawFormat}`,
children: (
<pre
style={{
maxHeight: '60vh',
overflow: 'auto',
background: 'var(--gn-code-bg, #f1f3f5)',
padding: 12,
borderRadius: 4,
fontSize: 12,
fontFamily: 'ui-monospace, Consolas, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{report.plan.rawPayload || '(无原文)'}
</pre>
),
},
]}
/>
)}
<div style={{ minHeight: 480, height: '70vh' }}>
<ExplainReportView
config={config}
dbName={dbName}
sql={sql}
runKey={open ? `${dbName}::${sql}` : null}
/>
</div>
</Modal>
)
}
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;
}
`

View File

@@ -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<SlowQueryRecord[]>([])
const [error, setError] = useState<string | null>(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 (
<Modal
open={open}
onCancel={onClose}
footer={null}
width="70%"
style={{ top: 40 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#fa5252' }} />
<Title level={5} style={{ margin: 0 }}> SQL </Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{dbName || '(当前连接)'}
</Text>
</div>
}
destroyOnClose
>
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Segmented
value={sortBy}
@@ -148,12 +144,46 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
)}
{!loading && !error && sorted.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, maxHeight: '60vh', overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, flex: 1, minHeight: 0, overflowY: 'auto' }}>
{sorted.map((r, idx) => (
<SlowQueryCard key={r.id ?? idx} record={r} onPick={() => handlePick(r)} />
))}
</div>
)}
</div>
)
}
export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width="70%"
style={{ top: 40 }}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#fa5252' }} />
<Title level={5} style={{ margin: 0 }}> SQL </Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{dbName || '(当前连接)'}
</Text>
</div>
}
destroyOnClose
>
<div style={{ minHeight: 480, height: '60vh' }}>
<SlowQueryPanelContent
config={config}
dbName={dbName}
activeToken={open ? `${dbName}:open` : null}
onPickQuery={(sql) => {
onPickQuery?.(sql)
onClose()
}}
/>
</div>
</Modal>
)
}

View File

@@ -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<SqlAnalysisViewKey>(() => 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 (
<div className="gn-sql-analysis-workbench">
<style>{workbenchStyles}</style>
<Alert
type="warning"
showIcon
message="当前工作台对应的连接已不可用"
description="请重新选择一个有效连接后再打开 SQL 分析工作台。"
/>
</div>
)
}
return (
<div className="gn-sql-analysis-workbench">
<style>{workbenchStyles}</style>
<div className="gn-sql-analysis-workbench-header">
<div className="gn-sql-analysis-workbench-header-main">
<Title level={5} style={{ margin: 0 }}>
SQL
</Title>
<Text type="secondary">
{connection?.name || tab.connectionId}
{dbName ? ` / ${dbName}` : ''}
</Text>
</div>
<Segmented
value={activeView}
onChange={(value) => setActiveView(value as SqlAnalysisViewKey)}
className="gn-sql-analysis-view-switcher"
options={[
{
value: 'slow-query',
label: (
<span className="gn-sql-analysis-view-switcher-label">
<HistoryOutlined />
<span> SQL</span>
</span>
),
},
{
value: 'diagnose',
label: (
<span className="gn-sql-analysis-view-switcher-label">
<SearchOutlined />
<span>SQL </span>
</span>
),
},
]}
/>
</div>
<div className="gn-sql-analysis-workbench-body">
{activeView === 'slow-query' ? (
<div className="gn-sql-analysis-pane">
<SlowQueryPanelContent
config={connectionConfig}
dbName={dbName}
onPickQuery={handlePickSlowQuery}
activeToken={slowQueryLoadKey}
/>
</div>
) : (
<div className="gn-sql-analysis-pane">
<div className="gn-sql-analysis-editor-block">
<Input.TextArea
value={sqlDraft}
onChange={(event) => setSqlDraft(event.target.value)}
placeholder="输入要诊断的 SQL或从慢 SQL 列表点击条目带入"
autoSize={{ minRows: 5, maxRows: 10 }}
/>
<div className="gn-sql-analysis-editor-actions">
<Text type="secondary"> SQL </Text>
<Button type="primary" icon={<SearchOutlined />} onClick={triggerDiagnose}>
</Button>
</div>
</div>
<div className="gn-sql-analysis-report-shell">
<ExplainReportView
config={connectionConfig}
dbName={dbName}
sql={sqlDraft}
runKey={diagnoseRunKey > 0 ? diagnoseRunKey : null}
/>
</div>
</div>
)}
</div>
</div>
)
}
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;
}
`

View File

@@ -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 加载避免 SlowQueryPanelreact-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 SlowQueryPanelreact-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<ConnectionConfig | null>(() => {
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 (
<>
<Tooltip title={tooltipText} placement="right">
<button
type="button"
className={className}
onClick={() => !buttonDisabled && setOpen(true)}
disabled={buttonDisabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
border: 'none',
background: 'transparent',
cursor: buttonDisabled ? 'not-allowed' : 'pointer',
color: buttonDisabled
? 'var(--gn-text-muted, #adb5bd)'
: 'var(--gn-text, #495057)',
opacity: buttonDisabled ? 0.5 : 1,
transition: 'opacity 0.15s, color 0.15s',
...style,
}}
aria-label="慢 SQL 历史"
>
<HistoryOutlined style={{ fontSize: 16 }} />
</button>
</Tooltip>
{open && activeConfig && (
<Suspense fallback={null}>
<SlowQueryPanel
open={open}
onClose={() => setOpen(false)}
config={activeConfig}
dbName={dbName}
/>
</Suspense>
)}
</>
<Tooltip title={tooltipText} placement={tooltipPlacement}>
<button
type="button"
className={className}
onClick={() => {
if (buttonDisabled || !activeTab?.connectionId) {
return
}
addTab(buildSqlAnalysisWorkbenchTab({
connectionId: activeTab.connectionId,
dbName: activeTab.dbName,
view: 'slow-query',
}))
}}
disabled={buttonDisabled}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
border: 'none',
background: 'transparent',
cursor: buttonDisabled ? 'not-allowed' : 'pointer',
color: buttonDisabled
? 'var(--gn-text-muted, #adb5bd)'
: 'var(--gn-text, #495057)',
opacity: buttonDisabled ? 0.5 : 1,
transition: 'opacity 0.15s, color 0.15s',
...style,
}}
aria-label="慢 SQL 工作台"
>
<HistoryOutlined style={{ fontSize: 16 }} />
</button>
</Tooltip>
)
}

View File

@@ -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<Record<TableExportScope, string>>;
tableExportRowCountByScope?: Partial<Record<TableExportScope, number>>;
sqlAnalysisView?: "diagnose" | "slow-query";
sqlAnalysisRequestKey?: string;
formatRestoreSnapshot?: {
query: string;
createdAt: number;

View File

@@ -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')
})
})

View File

@@ -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()}`,
}
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "ビュー",

View File

@@ -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": "Представление",

View File

@@ -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": "视图",

View File

@@ -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": "視圖",