🐛 fix(i18n): 合并 dev 后补齐只读保护多语言

This commit is contained in:
tianqijiuyun-latiao
2026-06-23 17:05:35 +08:00
parent 839fdd7d66
commit 09ae3d74c4
45 changed files with 31438 additions and 29485 deletions

View File

@@ -77,6 +77,19 @@ const flushConnectionTestTick = async () => {
};
const source = readFileSync(new URL("./ConnectionModal.tsx", import.meta.url), "utf8");
const step2Source = readFileSync(new URL("./connectionModal/ConnectionModalStep2.tsx", import.meta.url), "utf8");
const networkSecuritySource = readFileSync(
new URL("./connectionModal/ConnectionModalNetworkSecuritySection.tsx", import.meta.url),
"utf8",
);
const uriSource = readFileSync(new URL("./connectionModal/connectionModalUri.ts", import.meta.url), "utf8");
const typeCatalogSource = readFileSync(new URL("../utils/connectionTypeCatalog.ts", import.meta.url), "utf8");
const combinedConnectionModalSource = [
source,
step2Source,
networkSecuritySource,
uriSource,
].join("\n");
const initialConnection = (type: string, config: Record<string, any> = {}) =>
({
@@ -856,12 +869,12 @@ describe("ConnectionModal i18n", () => {
});
it("localizes the Redis URI example separator while preserving URI examples as raw text", () => {
expect(source).not.toContain(`topology=cluster ${"\u6216"} redis://`);
expect(source).toContain('t("connection.modal.example.or"');
expect(source).toContain(
expect(uriSource).not.toContain(`topology=cluster ${"\u6216"} redis://`);
expect(uriSource).toContain('t("connection.modal.example.or"');
expect(uriSource).toContain(
'"redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster"',
);
expect(source).toContain(
expect(uriSource).toContain(
'"redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster"',
);
});
@@ -884,11 +897,11 @@ describe("ConnectionModal i18n", () => {
'label: "NoSQL"',
'name: "Custom (自定义)"',
].forEach((snippet) => {
expect(source).not.toContain(snippet);
expect(combinedConnectionModalSource).not.toContain(snippet);
});
expect(source).not.toContain('res?.message !== "已取消"');
expect(source.match(/isBackendCancelledResult\(res\)/g) ?? []).toHaveLength(3);
expect(source).toContain('name: "Dameng (达梦)"');
expect(combinedConnectionModalSource).not.toContain('res?.message !== "已取消"');
expect(combinedConnectionModalSource.match(/isBackendCancelledResult\(res\)/g) ?? []).toHaveLength(3);
expect(typeCatalogSource).toContain("name: 'Dameng (达梦)'");
});
it("renders English URI feedback and file picker error shell while preserving raw detail", async () => {

View File

@@ -5,6 +5,8 @@ const componentFiles = [
'./DataExportDialog.tsx',
'./ExportProgressModal.tsx',
'./TableExportWorkbench.tsx',
'./useExportProgressRunner.ts',
'../utils/tableExportTab.ts',
] as const;
const localeFiles = [
@@ -36,6 +38,12 @@ describe('data export i18n', () => {
expect(sources[0]).toContain("t('data_export.dialog.field.format')");
expect(sources[1]).toContain("t('data_export.progress.title.error')");
expect(sources[2]).toContain("t('data_export.workbench.title')");
expect(sources[3]).toContain("t('data_export.progress.title.done')");
expect(sources[3]).toContain("t('data_export.progress.title.error')");
expect(sources[4]).toContain("t('data_export.workbench.scope.all.label')");
expect(sources[4]).toContain("t('data_export.workbench.scope.all.description')");
expect(sources[4]).toContain("t('data_export.progress.value.target_fallback')");
expect(sources[4]).toContain("t('data_export.workbench.task.export_target'");
expect(combinedSource).not.toMatch(/\p{Script=Han}/u);
});

View File

@@ -2214,9 +2214,45 @@ describe('DataGrid layout', () => {
expect(source).toContain("type DataGridExportScope = 'selected' | 'page' | 'all' | 'filteredAll';");
expect(source).toContain('const handleOpenExportDialog = useCallback(async () => {');
expect(source).toContain('await runExportWithProgress({');
expect(source).toContain("title: '导出查询结果'");
expect(source).toContain("label: '筛选结果(全部)'");
expect(source).toContain("label: '全表数据'");
expect(source).toContain("translateDataGrid('file.backend.dialog.export_query_result')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.selected_rows')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.selected_rows_count'");
expect(source).toContain("translateDataGrid('data_grid.export.scope.selected_rows_description')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.current_page'");
expect(source).toContain("translateDataGrid('data_grid.export.scope.current_page_description')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.all_results_requery')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.all_results_cached'");
expect(source).toContain("translateDataGrid('data_grid.export.scope.all_results_requery_description')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.all_results_cached_description')");
expect(source).not.toContain("title: '导出查询结果'");
expect(source).not.toContain("title: `导出 ${defaultName || '查询结果'}`");
expect(source).not.toContain("? '全部结果(重新查询)'");
expect(source).not.toContain(": `全部结果(当前缓存 ${mergedDisplayData.length} 条)`");
expect(source).not.toContain("label: selectedCount > 0 ? `选中行 (${selectedCount} 条)` : '选中行'");
expect(source).not.toContain("description: '仅导出当前结果集中已勾选的行。'");
expect(source).not.toContain("label: `当前页 (${queryResultCurrentPageRows.length} 条)`");
expect(source).not.toContain("description: '直接按当前结果页缓存导出。'");
expect(source).not.toContain("? '后台会重新执行 SQL避免只导出当前页或当前缓存。'");
expect(source).not.toContain(": '当前查询缺少可重放 SQL 时,将导出当前缓存的全部结果。'");
expect(source).toContain("translateDataGrid('file.backend.dialog.export_table'");
expect(source).toContain("translateDataGrid('file.backend.dialog.export_data')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.current_page'");
expect(source).toContain("translateDataGrid('data_grid.export.scope.current_page_requery_description')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.current_page_unavailable_description')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.filtered_results_all')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.filtered_results_all_requery_description')");
expect(source).toContain("translateDataGrid('data_grid.export.scope.filtered_results_all_unavailable_description')");
expect(source).toContain("translateDataGrid('data_export.workbench.scope.all.label')");
expect(source).toContain("translateDataGrid('data_export.workbench.scope.all.description')");
expect(source).not.toContain("title: `导出 ${tableName || '数据'}`");
expect(source).not.toContain("label: `当前页 (${displayData.length} 条)`");
expect(source).not.toContain("? '后台按当前分页条件重新查询后导出当前页。'");
expect(source).not.toContain(": '当前页依赖前端临时状态,建议直接使用快捷导出。'");
expect(source).not.toContain("label: '筛选结果(全部)'");
expect(source).not.toContain("? '按当前筛选条件重新查询数据库并导出全部筛选结果。'");
expect(source).not.toContain(": '当前数据源或当前状态暂不支持在工作台重放筛选导出。'");
expect(source).not.toContain("label: '全表数据'");
expect(source).not.toContain("description: '后台重新查询整张表并导出全部数据。'");
expect(source).toContain("const fallbackAllSql = String(resultSql || '').trim();");
expect(source).toContain("const backendExportSql = exportAllSql || fallbackAllSql;");
expect(source).toContain("if (backendExportSql && connectionId) {");

View File

@@ -913,8 +913,11 @@ const DataGrid: React.FC<DataGridProps> = ({
// Helper to export specific data
const exportData = async (rows: any[], options: DataExportFileOptions) => {
const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames);
const exportTitle = String(tableName || '').trim()
? translateDataGrid('file.backend.dialog.export_table', { table: tableName })
: translateDataGrid('file.backend.dialog.export_data');
await runExportWithProgress({
title: `导出 ${tableName || '数据'}`,
title: exportTitle,
targetName: tableName || 'export',
format: options.format,
totalRows: cleanRows.length,

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
const tableOverviewSource = readFileSync(new URL('./TableOverview.tsx', import.meta.url), 'utf8');
const sidebarObjectActionsSource = readFileSync(new URL('./sidebar/useSidebarObjectActions.tsx', import.meta.url), 'utf8');
describe('export title i18n guards', () => {
it('keeps export progress and export tab titles on translation keys instead of inline Chinese copy', () => {
[
"`导出 ${tableName || '数据'}`",
"`导出 ${tableName}`",
].forEach((rawSnippet) => {
expect(dataGridSource).not.toContain(rawSnippet);
expect(tableOverviewSource).not.toContain(rawSnippet);
expect(sidebarObjectActionsSource).not.toContain(rawSnippet);
});
expect(dataGridSource).toContain("translateDataGrid('file.backend.dialog.export_data')");
expect(dataGridSource).toContain("translateDataGrid('file.backend.dialog.export_table'");
expect(tableOverviewSource).toContain("t('file.backend.dialog.export_table'");
expect(sidebarObjectActionsSource).toContain("t('file.backend.dialog.export_table'");
});
});

View File

@@ -373,6 +373,7 @@ vi.mock('@ant-design/icons', () => {
return {
BugOutlined: Icon,
ClearOutlined: Icon,
CopyOutlined: Icon,
PlayCircleOutlined: Icon,
SaveOutlined: Icon,
FormatPainterOutlined: Icon,
@@ -479,6 +480,24 @@ const textContent = (node: any): string => {
return textContent(node.children || []);
};
const queryResultMessageText = (renderer: ReactTestRenderer): string => {
const values: string[] = [];
const walk = (node: any) => {
if (!node) return;
if (Array.isArray(node)) {
node.forEach(walk);
return;
}
if (typeof node !== 'object') return;
if (typeof node.props?.['data-query-result-message-textarea'] === 'string') {
values.push(String(node.props.value || ''));
}
walk(node.children || []);
};
walk(renderer.toJSON());
return values.join('\n');
};
const findButton = (renderer: ReactTestRenderer, text: string) =>
renderer.root.findAll((node) => node.type === 'button' && textContent(node).includes(text))[0];
@@ -789,8 +808,9 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 1');
expect(textContent(renderer!.toJSON())).toContain("Table 'users'. Scan count 1, logical reads 3.");
const rendered = `${textContent(renderer!.toJSON())}\n${queryResultMessageText(renderer!)}`;
expect(rendered).toContain('消息 1');
expect(rendered).toContain("Table 'users'. Scan count 1, logical reads 3.");
expect(dataGridState.latestProps?.columnNames).not.toEqual([]);
});
@@ -945,9 +965,10 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 2');
expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
const rendered = `${textContent(renderer!.toJSON())}\n${queryResultMessageText(renderer!)}`;
expect(rendered).toContain('消息 2');
expect(rendered).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(rendered).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
});
@@ -983,7 +1004,7 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
const rendered = textContent(renderer!.toJSON());
const rendered = `${textContent(renderer!.toJSON())}\n${queryResultMessageText(renderer!)}`;
expect(rendered).toContain('消息 1');
expect(rendered).toContain("select c.queryno,'' ,left(dbo.f_vendor_class");
expect(rendered).toContain("'char','',''),'自动生成'");
@@ -1017,9 +1038,10 @@ describe('QueryEditor external SQL save', () => {
await Promise.resolve();
});
expect(textContent(renderer!.toJSON())).toContain('消息 2');
expect(textContent(renderer!.toJSON())).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(textContent(renderer!.toJSON())).not.toContain('影响行数0');
const rendered = `${textContent(renderer!.toJSON())}\n${queryResultMessageText(renderer!)}`;
expect(rendered).toContain('消息 2');
expect(rendered).toContain("insert into c_dyscript(projectid,name) values (1,'demo')");
expect(rendered).not.toContain('影响行数0');
expect(dataGridState.latestProps).toBeNull();
});

View File

@@ -0,0 +1,366 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const
const catalogs = Object.fromEntries(locales.map((locale) => [
locale,
JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>,
])) as Record<typeof locales[number], Record<string, string>>
const sqlAnalysisWorkbenchSource = readFileSync(new URL('./explain/SqlAnalysisWorkbench.tsx', import.meta.url), 'utf8')
const explainWorkbenchSource = readFileSync(new URL('./explain/ExplainWorkbench.tsx', import.meta.url), 'utf8')
const slowQueryPanelSource = readFileSync(new URL('./explain/SlowQueryPanel.tsx', import.meta.url), 'utf8')
const explainGraphSource = readFileSync(new URL('./explain/ExplainGraph.tsx', import.meta.url), 'utf8')
const explainSidebarSource = readFileSync(new URL('./explain/ExplainSidebar.tsx', import.meta.url), 'utf8')
const slowQueryRailButtonSource = readFileSync(new URL('./sidebar/SlowQueryRailButton.tsx', import.meta.url), 'utf8')
const queryEditorSource = readFileSync(new URL('./QueryEditor.tsx', import.meta.url), 'utf8')
const stripLineComments = (source: string): string => (
source.replace(/^\s*\/\/.*$/gm, '')
)
const sqlAnalysisWorkbenchRuntimeSource = stripLineComments(sqlAnalysisWorkbenchSource)
const explainWorkbenchRuntimeSource = stripLineComments(explainWorkbenchSource)
const slowQueryPanelRuntimeSource = stripLineComments(slowQueryPanelSource)
const explainGraphRuntimeSource = stripLineComments(explainGraphSource)
const explainSidebarRuntimeSource = stripLineComments(explainSidebarSource)
const slowQueryRailButtonRuntimeSource = stripLineComments(slowQueryRailButtonSource)
const placeholdersOf = (value: string): string[] => (
Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), (match) => match[1]).sort()
)
const requiredKeys = [
'sql_analysis.workbench.validation.sql_required',
'sql_analysis.workbench.alert.connection_missing_title',
'sql_analysis.workbench.alert.connection_missing_description',
'sql_analysis.workbench.title',
'sql_analysis.workbench.view.slow_query',
'sql_analysis.workbench.view.diagnose',
'sql_analysis.workbench.editor.placeholder',
'sql_analysis.workbench.editor.hint',
'sql_analysis.workbench.action.run',
'sql_analysis.explain.error.query_required',
'sql_analysis.explain.error.run_failed',
'sql_analysis.explain.loading',
'sql_analysis.explain.error.title',
'sql_analysis.explain.empty',
'sql_analysis.explain.view.plan',
'sql_analysis.explain.view.raw',
'sql_analysis.explain.meta.node_count',
'sql_analysis.explain.raw.empty',
'sql_analysis.explain_graph.label.table',
'sql_analysis.explain_graph.label.index',
'sql_analysis.explain_graph.metric.est_rows',
'sql_analysis.explain_graph.metric.actual_rows',
'sql_analysis.explain_graph.metric.cost',
'sql_analysis.explain_graph.flag.full_scan',
'sql_analysis.explain_graph.flag.filesort',
'sql_analysis.explain_graph.flag.temp_table',
'sql_analysis.sidebar.stats.title',
'sql_analysis.sidebar.stats.total_cost',
'sql_analysis.sidebar.stats.total_duration',
'sql_analysis.sidebar.stats.rows_read',
'sql_analysis.sidebar.stats.buffer_hit',
'sql_analysis.sidebar.stats.max_est_rows',
'sql_analysis.sidebar.warning.full_scan',
'sql_analysis.sidebar.warning.filesort',
'sql_analysis.sidebar.warning.temp_table',
'sql_analysis.sidebar.node.title',
'sql_analysis.sidebar.node.op_type',
'sql_analysis.sidebar.node.op_detail',
'sql_analysis.sidebar.node.table',
'sql_analysis.sidebar.node.index',
'sql_analysis.sidebar.node.est_rows',
'sql_analysis.sidebar.node.actual_rows',
'sql_analysis.sidebar.node.loops',
'sql_analysis.sidebar.node.cost',
'sql_analysis.sidebar.node.duration',
'sql_analysis.sidebar.node.buffer_hit',
'sql_analysis.sidebar.node.flags',
'sql_analysis.sidebar.node.extra',
'sql_analysis.sidebar.suggestions.title',
'sql_analysis.sidebar.suggestions.empty',
'sql_analysis.sidebar.suggestions.rows',
'sql_analysis.sidebar.suggestions.table',
'sql_analysis.slow_query.error.load_failed',
'sql_analysis.slow_query.message.cleared',
'sql_analysis.slow_query.error.clear_failed',
'sql_analysis.slow_query.sort.duration',
'sql_analysis.slow_query.sort.rows_read',
'sql_analysis.slow_query.sort.recent',
'sql_analysis.slow_query.tooltip.clear_current',
'sql_analysis.slow_query.loading',
'sql_analysis.slow_query.error.title',
'sql_analysis.slow_query.empty',
'sql_analysis.slow_query.title',
'sql_analysis.slow_query.current_connection',
'sql_analysis.slow_query.metric.rows_read',
'sql_analysis.slow_query.metric.rows_returned',
'sql_analysis.slow_query.preview.empty',
'sql_analysis.slow_query.relative.just_now',
'sql_analysis.slow_query.relative.minutes_ago',
'sql_analysis.slow_query.relative.hours_ago',
'sql_analysis.slow_query.relative.days_ago',
'sql_analysis.slow_query.rail.tooltip.no_connection',
'sql_analysis.slow_query.rail.tooltip.open',
'sql_analysis.slow_query.rail.aria_label',
] as const
describe('SQL analysis workbench i18n', () => {
it('localizes the sql analysis workbench shell copy', () => {
;[
'请输入要诊断的 SQL',
'当前工作台对应的连接已不可用',
'请重新选择一个有效连接后再打开 SQL 分析工作台。',
'SQL 分析工作台',
'慢 SQL',
'SQL 诊断',
'输入要诊断的 SQL或从慢 SQL 列表点击条目带入',
'支持从慢 SQL 列表点击条目直接带入',
'运行诊断',
].forEach((text) => {
expect(sqlAnalysisWorkbenchRuntimeSource).not.toContain(text)
})
;[
'useI18n(',
"t('sql_analysis.workbench.validation.sql_required')",
"t('sql_analysis.workbench.alert.connection_missing_title')",
"t('sql_analysis.workbench.alert.connection_missing_description')",
"t('sql_analysis.workbench.title')",
"t('sql_analysis.workbench.view.slow_query')",
"t('sql_analysis.workbench.view.diagnose')",
"t('sql_analysis.workbench.editor.placeholder')",
"t('sql_analysis.workbench.editor.hint')",
"t('sql_analysis.workbench.action.run')",
].forEach((text) => {
expect(sqlAnalysisWorkbenchSource).toContain(text)
})
})
it('localizes explain report copy while keeping raw payload output untouched', () => {
;[
'查询语句为空',
'诊断失败',
'正在执行 EXPLAIN 并解析计划...',
'输入 SQL 后运行诊断',
'执行计划',
'原文',
'节点',
'(无原文)',
'SQL 诊断工作台',
].forEach((text) => {
expect(explainWorkbenchRuntimeSource).not.toContain(text)
})
;[
'useI18n(',
"t('sql_analysis.explain.error.query_required')",
"t('sql_analysis.explain.error.run_failed')",
"t('sql_analysis.explain.loading')",
"t('sql_analysis.explain.error.title')",
"t('sql_analysis.explain.empty')",
"t('sql_analysis.explain.view.plan')",
"t('sql_analysis.explain.view.raw')",
"t('sql_analysis.explain.meta.node_count'",
"t('sql_analysis.explain.raw.empty')",
"t('sql_analysis.workbench.title')",
].forEach((text) => {
expect(explainWorkbenchSource).toContain(text)
})
expect(explainWorkbenchSource).toContain('report.plan.rawPayload')
})
it('localizes slow query panel copy while keeping sql preview and db type raw', () => {
;[
'加载失败',
'已清空慢查询历史',
'清空失败',
'按耗时',
'按扫描行数',
'按时间',
'刷新',
'清空当前连接的历史',
'加载慢查询历史...',
'暂无慢查询记录(阈值 500ms',
'慢 SQL 历史',
'(当前连接)',
'扫描',
'返回',
'(无 SQL 预览)',
'刚刚',
'分钟前',
'小时前',
'天前',
].forEach((text) => {
expect(slowQueryPanelRuntimeSource).not.toContain(text)
})
;[
'useI18n(',
"t('common.refresh')",
"t('sql_analysis.slow_query.error.load_failed')",
"t('sql_analysis.slow_query.message.cleared')",
"t('sql_analysis.slow_query.error.clear_failed')",
"t('sql_analysis.slow_query.sort.duration')",
"t('sql_analysis.slow_query.sort.rows_read')",
"t('sql_analysis.slow_query.sort.recent')",
"t('sql_analysis.slow_query.tooltip.clear_current')",
"t('sql_analysis.slow_query.loading')",
"t('sql_analysis.slow_query.error.title')",
"t('sql_analysis.slow_query.empty'",
"t('sql_analysis.slow_query.title')",
"t('sql_analysis.slow_query.current_connection')",
"t('sql_analysis.slow_query.metric.rows_read')",
"t('sql_analysis.slow_query.metric.rows_returned')",
"t('sql_analysis.slow_query.preview.empty')",
"t('sql_analysis.slow_query.relative.just_now')",
"t('sql_analysis.slow_query.relative.minutes_ago'",
"t('sql_analysis.slow_query.relative.hours_ago'",
"t('sql_analysis.slow_query.relative.days_ago'",
].forEach((text) => {
expect(slowQueryPanelSource).toContain(text)
})
expect(slowQueryPanelSource).toContain('record.sqlPreview')
expect(slowQueryPanelSource).toContain('record.dbType')
})
it('localizes explain graph, explain sidebar and slow-query rail labels', () => {
;[
'表:',
'索引:',
'估算',
'实际',
'成本',
'全表扫描',
'额外排序',
'临时表',
].forEach((text) => {
expect(explainGraphRuntimeSource).not.toContain(text)
})
;[
'useI18n(',
"t('sql_analysis.explain_graph.label.table')",
"t('sql_analysis.explain_graph.label.index')",
"t('sql_analysis.explain_graph.metric.est_rows')",
"t('sql_analysis.explain_graph.metric.actual_rows')",
"t('sql_analysis.explain_graph.metric.cost')",
"t('sql_analysis.explain_graph.flag.full_scan')",
"t('sql_analysis.explain_graph.flag.filesort')",
"t('sql_analysis.explain_graph.flag.temp_table')",
].forEach((text) => {
expect(explainGraphSource).toContain(text)
})
;[
'执行统计',
'总成本',
'总耗时',
'扫描行数',
'缓冲命中',
'最大单节点行数',
'存在全表扫描',
'存在额外排序',
'使用临时表',
'操作类型',
'操作详情',
'表',
'索引',
'估算行数',
'实际行数',
'循环次数',
'标志',
'节点详情',
'Extra 字段',
'索引建议',
'未发现明显性能问题',
'行',
'表:',
].forEach((text) => {
expect(explainSidebarRuntimeSource).not.toContain(text)
})
;[
'useI18n(',
"t('sql_analysis.sidebar.stats.title')",
"t('sql_analysis.sidebar.stats.total_cost')",
"t('sql_analysis.sidebar.stats.total_duration')",
"t('sql_analysis.sidebar.stats.rows_read')",
"t('sql_analysis.sidebar.stats.buffer_hit')",
"t('sql_analysis.sidebar.stats.max_est_rows')",
"t('sql_analysis.sidebar.warning.full_scan')",
"t('sql_analysis.sidebar.warning.filesort')",
"t('sql_analysis.sidebar.warning.temp_table')",
"t('sql_analysis.sidebar.node.title')",
"t('sql_analysis.sidebar.node.op_type')",
"t('sql_analysis.sidebar.node.op_detail')",
"t('sql_analysis.sidebar.node.table')",
"t('sql_analysis.sidebar.node.index')",
"t('sql_analysis.sidebar.node.est_rows')",
"t('sql_analysis.sidebar.node.actual_rows')",
"t('sql_analysis.sidebar.node.loops')",
"t('sql_analysis.sidebar.node.cost')",
"t('sql_analysis.sidebar.node.duration')",
"t('sql_analysis.sidebar.node.buffer_hit')",
"t('sql_analysis.sidebar.node.flags')",
"t('sql_analysis.sidebar.node.extra'",
"t('sql_analysis.sidebar.suggestions.title'",
"t('sql_analysis.sidebar.suggestions.empty')",
"t('sql_analysis.sidebar.suggestions.rows'",
"t('sql_analysis.sidebar.suggestions.table'",
].forEach((text) => {
expect(explainSidebarSource).toContain(text)
})
;[
'请先打开一个数据库连接的标签页',
'打开当前连接的 SQL 分析工作台',
'慢 SQL 工作台',
].forEach((text) => {
expect(slowQueryRailButtonRuntimeSource).not.toContain(text)
})
;[
'useI18n(',
"t('sql_analysis.slow_query.rail.tooltip.no_connection')",
"t('sql_analysis.slow_query.rail.tooltip.open')",
"t('sql_analysis.slow_query.rail.aria_label')",
'buildSqlAnalysisWorkbenchTab',
"view: 'slow-query'",
].forEach((text) => {
expect(slowQueryRailButtonSource).toContain(text)
})
})
it('uses shortcut translation keys without Chinese fallback labels in query editor menus', () => {
;[
"{translate('app.shortcuts.action.diagnoseQuery.label' as any) || 'SQL 诊断'}",
"{translate('app.shortcuts.action.showSlowQueries.label' as any) || '慢 SQL 历史'}",
].forEach((text) => {
expect(queryEditorSource).not.toContain(text)
})
;[
"translate('app.shortcuts.action.diagnoseQuery.label' as any)",
"translate('app.shortcuts.action.showSlowQueries.label' as any)",
].forEach((text) => {
expect(queryEditorSource).toContain(text)
})
})
it('keeps sql analysis catalog keys in all supported languages with matching placeholders', () => {
const zhCnCatalog = catalogs['zh-CN']
requiredKeys.forEach((key) => {
expect(zhCnCatalog, `zh-CN:${key}`).toHaveProperty(key)
const expectedPlaceholders = placeholdersOf(zhCnCatalog[key])
locales.forEach((locale) => {
expect(catalogs[locale], `${locale}:${key}`).toHaveProperty(key)
expect(placeholdersOf(catalogs[locale][key]), `${locale}:${key}`).toEqual(expectedPlaceholders)
})
})
})
})

View File

@@ -4073,7 +4073,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
key: 'diagnose-query',
label: (
<span>
{translate('app.shortcuts.action.diagnoseQuery.label' as any) || 'SQL 诊断'}
{translate('app.shortcuts.action.diagnoseQuery.label' as any)}
{diagnoseQueryShortcutBinding?.enabled && diagnoseQueryShortcutBinding.combo && (
<span style={{ marginLeft: 8, color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
{getShortcutDisplayLabel(diagnoseQueryShortcutBinding.combo, activeShortcutPlatform)}
@@ -4087,7 +4087,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
key: 'show-slow-queries',
label: (
<span>
{translate('app.shortcuts.action.showSlowQueries.label' as any) || '慢 SQL 历史'}
{translate('app.shortcuts.action.showSlowQueries.label' as any)}
{showSlowQueriesShortcutBinding?.enabled && showSlowQueriesShortcutBinding.combo && (
<span style={{ marginLeft: 8, color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
{getShortcutDisplayLabel(showSlowQueriesShortcutBinding.combo, activeShortcutPlatform)}

View File

@@ -2949,8 +2949,16 @@ const Sidebar: React.FC<{
modalScrollSectionStyle={modalScrollSectionStyle}
modalHintTextStyle={modalHintTextStyle}
darkMode={darkMode}
tableModalTitle={renderSidebarModalTitle(<TableOutlined />, "批量操作表", "按对象批量导出结构、数据或完整备份。")}
databaseModalTitle={renderSidebarModalTitle(<DatabaseOutlined />, "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")}
tableModalTitle={renderSidebarModalTitle(
<TableOutlined />,
t('sidebar.modal.batch_tables.title'),
t('sidebar.modal.batch_tables.description'),
)}
databaseModalTitle={renderSidebarModalTitle(
<DatabaseOutlined />,
t('sidebar.modal.batch_databases.title'),
t('sidebar.modal.batch_databases.description'),
)}
isBatchModalOpen={isBatchModalOpen}
setIsBatchModalOpen={setIsBatchModalOpen}
selectedConnection={selectedConnection}

View File

@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const objectActionsSource = readFileSync(new URL('./sidebar/useSidebarObjectActions.tsx', import.meta.url), 'utf8');
const searchModelSource = readFileSync(new URL('./sidebar/useSidebarSearchModel.tsx', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const requiredKeys = [
'sidebar.message.ai_table_context_missing',
@@ -9,7 +11,6 @@ const requiredKeys = [
'sidebar.ai_prompt.explain.detail',
'sidebar.ai_prompt.query.intro',
'sidebar.ai_prompt.query.detail',
'sidebar.command_search.action.ask_ai.title',
];
describe('Sidebar AI prompt i18n', () => {
@@ -23,18 +24,21 @@ describe('Sidebar AI prompt i18n', () => {
"title: '让 AI 回答'",
].forEach((legacyCopy) => {
expect(source).not.toContain(legacyCopy);
expect(objectActionsSource).not.toContain(legacyCopy);
expect(searchModelSource).not.toContain(legacyCopy);
});
requiredKeys.forEach((key) => {
expect(source).toContain(`t('${key}'`);
expect(objectActionsSource).toContain(`t('${key}'`);
});
expect(source).toContain('DBShowCreateTable');
expect(source).toContain('conn.dbName');
expect(source).toContain('tableName');
expect(source).toContain('ddl ? `\\n\\`\\`\\`sql');
expect(source).toContain('${ddl}');
expect(source).toContain('v2CommandSearchQuery.aiPrompt');
expect(objectActionsSource).toContain('DBShowCreateTable');
expect(objectActionsSource).toContain('conn.dbName');
expect(objectActionsSource).toContain('tableName');
expect(objectActionsSource).toContain('ddl ? `\\n\\`\\`\\`sql');
expect(objectActionsSource).toContain('${ddl}');
expect(searchModelSource).toContain("t('sidebar.command_search.action.ask_ai.title')");
expect(searchModelSource).toContain('v2CommandSearchQuery.aiPrompt');
});
it('keeps AI prompt keys available in every locale', () => {

View File

@@ -2,6 +2,9 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const batchModalSource = readFileSync(new URL('./sidebar/SidebarBatchExportModals.tsx', import.meta.url), 'utf8');
const batchHookSource = readFileSync(new URL('./sidebar/useSidebarBatchExport.ts', import.meta.url), 'utf8');
const batchTabSource = readFileSync(new URL('../utils/tableExportTab.ts', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const requiredKeys = [
@@ -38,8 +41,15 @@ const requiredKeys = [
'sidebar.batch.group.tables',
'sidebar.batch.group.views',
'sidebar.batch.no_matching_objects',
'sidebar.tab.batch_export_objects',
'sidebar.tab.batch_export_objects_database',
'sidebar.tab.batch_export_databases',
] as const;
const placeholders = (value: string): string[] => [...value.matchAll(/\{\{(\w+)\}\}/g)]
.map((match) => match[1])
.sort();
describe('Sidebar batch actions i18n', () => {
it('localizes batch table and database action copy', () => {
[
@@ -47,10 +57,16 @@ describe('Sidebar batch actions i18n', () => {
'批量操作库',
'按对象批量导出结构、数据或完整备份。',
'按数据库批量导出结构,或生成结构加数据的备份。',
].forEach((rawSnippet) => {
expect(sidebarSource).not.toContain(rawSnippet);
});
[
'清空表',
'导出结构',
'仅数据(INSERT)',
'备份(结构+数据)',
'取消',
'选择连接:',
'选择数据库:',
'请选择连接',
@@ -71,12 +87,81 @@ describe('Sidebar batch actions i18n', () => {
'连接选定后会加载当前连接下可批量导出的数据库列表。',
'个库',
].forEach((rawSnippet) => {
expect(sidebarSource).not.toContain(rawSnippet);
expect(batchModalSource).not.toContain(rawSnippet);
});
requiredKeys.forEach((key) => {
[
'`批量导出 ${dbName} 对象`',
"'批量导出对象'",
"title: '批量导出库'",
].forEach((rawSnippet) => {
expect(batchHookSource).not.toContain(rawSnippet);
});
[
"title: String(input.title || '批量导出对象').trim() || '批量导出对象'",
"title: String(input.title || '批量导出库').trim() || '批量导出库'",
].forEach((rawSnippet) => {
expect(batchTabSource).not.toContain(rawSnippet);
});
[
'sidebar.action.batch_tables',
'sidebar.action.batch_databases',
'sidebar.modal.batch_tables.title',
'sidebar.modal.batch_tables.description',
'sidebar.modal.batch_databases.title',
'sidebar.modal.batch_databases.description',
].forEach((key) => {
expect(sidebarSource, key).toContain(`t('${key}'`);
});
[
'sidebar.action.clear_tables',
'sidebar.action.export_schema',
'sidebar.action.export_data_only',
'sidebar.action.backup_schema_data',
'sidebar.action.select_all',
'sidebar.action.clear_selection',
'sidebar.action.invert_selection',
'sidebar.action.export_database_schema_count',
'sidebar.action.backup_database_count',
'sidebar.field.select_connection',
'sidebar.field.select_database',
'sidebar.placeholder.select_connection',
'sidebar.placeholder.select_connection_first',
'sidebar.placeholder.filter_table_view',
'sidebar.filter.all_objects',
'sidebar.filter.tables_only',
'sidebar.filter.views_only',
'sidebar.filter.scope_filtered',
'sidebar.filter.scope_all',
'sidebar.modal.batch_tables.selection_hint',
'sidebar.modal.batch_databases.selection_hint',
'sidebar.batch.filtered_count',
'sidebar.batch.selected_objects',
'sidebar.batch.selected_databases',
'sidebar.batch.group.tables',
'sidebar.batch.group.views',
'sidebar.batch.no_matching_objects',
].forEach((key) => {
expect(batchModalSource, key).toContain(`t('${key}'`);
});
[
'sidebar.tab.batch_export_objects',
'sidebar.tab.batch_export_objects_database',
'sidebar.tab.batch_export_databases',
].forEach((key) => {
expect(batchHookSource, key).toContain(`t('${key}'`);
});
[
'sidebar.tab.batch_export_objects',
'sidebar.tab.batch_export_databases',
].forEach((key) => {
expect(batchTabSource, key).toContain(`t('${key}'`);
});
});
it('keeps batch action catalog entries available in every locale', () => {
@@ -85,6 +170,9 @@ describe('Sidebar batch actions i18n', () => {
requiredKeys.forEach((key) => {
expect(catalog[key], `${locale}:${key}`).toBeTruthy();
});
expect(placeholders(catalog['sidebar.tab.batch_export_objects'])).toEqual([]);
expect(placeholders(catalog['sidebar.tab.batch_export_objects_database'])).toEqual(['database']);
expect(placeholders(catalog['sidebar.tab.batch_export_databases'])).toEqual([]);
});
});
});

View File

@@ -1,7 +1,8 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const objectActionsSource = readFileSync(new URL('./sidebar/useSidebarObjectActions.tsx', import.meta.url), 'utf8');
const legacyMenuSource = readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8');
const tableDataDangerActionsSource = readFileSync(new URL('./tableDataDangerActions.ts', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
@@ -12,6 +13,7 @@ const requiredKeys = [
'sidebar.message.schema_renamed',
'sidebar.message.schema_target_delete_missing',
'sidebar.message.schema_deleted',
'sidebar.message.table_export_target_missing',
'sidebar.modal.confirm_delete_schema.title',
'sidebar.modal.confirm_delete_schema.content',
'sidebar.menu.edit_schema',
@@ -20,6 +22,7 @@ const requiredKeys = [
'sidebar.menu.delete_schema',
'sidebar.menu.copy_object_name',
'sidebar.menu.table_structure',
'sidebar.menu.design_table',
'sidebar.menu.copy_table_name',
'sidebar.menu.copy_table_structure',
'sidebar.menu.backup_table_sql',
@@ -28,24 +31,26 @@ const requiredKeys = [
'sidebar.menu.clear_table',
'sidebar.menu.delete_table',
'sidebar.menu.export_table_data',
'sidebar.menu.export_csv',
'sidebar.menu.export_xlsx',
'sidebar.menu.export_json',
'sidebar.menu.export_markdown',
'sidebar.menu.export_html',
'sidebar.v2_table_menu.new_rollup',
'sidebar.message.table_name_required',
'sidebar.message.table_name_unchanged',
'sidebar.message.table_renamed',
'sidebar.message.table_deleted',
'sidebar.modal.confirm_delete_table.title',
'sidebar.modal.confirm_delete_table.content',
'sidebar.message.view_name_required',
'sidebar.message.view_name_unchanged',
'sidebar.message.view_renamed',
'sidebar.message.view_deleted',
'sidebar.modal.confirm_delete_view.title',
'sidebar.modal.confirm_delete_view.content',
'sidebar.message.rename_failed',
'sidebar.message.delete_failed',
'sidebar.message.table_data_action_loading',
'sidebar.message.table_data_action_success',
'sidebar.message.table_data_action_failed',
'sidebar.modal.confirm_table_data_action.title',
'sidebar.modal.confirm_table_data_action.content',
'sidebar.table_action.truncate.label',
'sidebar.table_action.truncate.progress',
'sidebar.table_action.clear.label',
@@ -65,7 +70,10 @@ describe('Sidebar object actions i18n', () => {
'确定删除模式',
'模式删除成功',
'删除失败: ',
'未识别到表名,无法导出',
'新增 Rollup',
'表名不能为空',
'新旧表名相同,无需修改',
'表重命名成功',
'确认删除表',
'确定删除表',
@@ -76,10 +84,16 @@ describe('Sidebar object actions i18n', () => {
'${progressLabel}成功',
'${progressLabel}失败',
'视图名称不能为空',
'新旧视图名相同,无需修改',
'视图重命名成功',
'确认删除视图',
'确定删除视图',
'视图删除成功',
].forEach((rawSnippet) => {
expect(objectActionsSource).not.toContain(rawSnippet);
});
[
"label: '编辑模式'",
"label: '导出当前模式表结构 (SQL)'",
"label: '备份当前模式全部表 (结构+数据 SQL)'",
@@ -96,9 +110,8 @@ describe('Sidebar object actions i18n', () => {
"label: '清空表'",
"label: '删除表'",
"label: '导出表数据'",
"label: '导出 CSV'",
].forEach((rawSnippet) => {
expect(sidebarSource).not.toContain(rawSnippet);
expect(legacyMenuSource).not.toContain(rawSnippet);
});
[
@@ -107,9 +120,35 @@ describe('Sidebar object actions i18n', () => {
"t('sidebar.message.schema_name_unchanged')",
"t('sidebar.message.schema_renamed')",
"t('sidebar.message.schema_target_delete_missing')",
"t('sidebar.message.table_export_target_missing')",
"t('sidebar.message.schema_deleted')",
"t('sidebar.v2_table_menu.new_rollup'",
"t('sidebar.modal.confirm_delete_schema.title')",
"t('sidebar.modal.confirm_delete_schema.content'",
"t('sidebar.message.table_name_required')",
"t('sidebar.message.table_name_unchanged')",
"t('sidebar.message.table_renamed')",
"t('sidebar.message.table_deleted')",
"t('sidebar.modal.confirm_delete_table.title')",
"t('sidebar.modal.confirm_delete_table.content'",
"t('sidebar.message.view_name_required')",
"t('sidebar.message.view_name_unchanged')",
"t('sidebar.message.view_renamed')",
"t('sidebar.message.view_deleted')",
"t('sidebar.modal.confirm_delete_view.title')",
"t('sidebar.modal.confirm_delete_view.content'",
"t('sidebar.message.rename_failed'",
"t('sidebar.message.delete_failed'",
"t('sidebar.message.table_data_action_loading'",
"t('sidebar.message.table_data_action_success'",
"t('sidebar.message.table_data_action_failed'",
"t('sidebar.modal.confirm_table_data_action.title'",
"t('sidebar.modal.confirm_table_data_action.content'",
].forEach((lookup) => {
expect(objectActionsSource).toContain(lookup);
});
[
"t('sidebar.menu.edit_schema')",
"t('sidebar.menu.export_current_schema_sql')",
"t('sidebar.menu.backup_current_schema_sql')",
@@ -117,6 +156,7 @@ describe('Sidebar object actions i18n', () => {
"t('sidebar.menu.copy_object_name')",
"t('message_publish_modal.title')",
"t('sidebar.menu.table_structure')",
"t('sidebar.menu.design_table')",
"t('sidebar.menu.copy_table_name')",
"t('sidebar.menu.copy_table_structure')",
"t('sidebar.menu.backup_table_sql')",
@@ -125,26 +165,8 @@ describe('Sidebar object actions i18n', () => {
"t('sidebar.menu.clear_table')",
"t('sidebar.menu.delete_table')",
"t('sidebar.menu.export_table_data')",
"t('sidebar.menu.export_csv')",
"t('sidebar.menu.export_xlsx')",
"t('sidebar.menu.export_json')",
"t('sidebar.menu.export_markdown')",
"t('sidebar.menu.export_html')",
"t('sidebar.message.table_name_required')",
"t('sidebar.message.table_name_unchanged')",
"t('sidebar.message.table_renamed')",
"t('sidebar.message.table_deleted')",
"t('sidebar.message.view_name_required')",
"t('sidebar.message.view_name_unchanged')",
"t('sidebar.message.view_renamed')",
"t('sidebar.message.view_deleted')",
"t('sidebar.message.rename_failed'",
"t('sidebar.message.delete_failed'",
"t('sidebar.message.table_data_action_loading'",
"t('sidebar.message.table_data_action_success'",
"t('sidebar.message.table_data_action_failed'",
].forEach((lookup) => {
expect(sidebarSource).toContain(lookup);
expect(legacyMenuSource).toContain(lookup);
});
});

View File

@@ -1,7 +1,9 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8');
const objectActionsSource = readFileSync(new URL('./sidebar/useSidebarObjectActions.tsx', import.meta.url), 'utf8');
const legacyMenuSource = readFileSync(new URL('./sidebar/sidebarLegacyNodeMenu.tsx', import.meta.url), 'utf8');
const v2ActionHandlersSource = readFileSync(new URL('./sidebar/useSidebarV2ActionHandlers.tsx', import.meta.url), 'utf8');
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const requiredKeys = [
@@ -31,12 +33,24 @@ describe('Sidebar residual actions i18n', () => {
'重命名查询失败: ',
'查询已绑定到 ',
'绑定查询失败: ',
'数据库创建成功',
'创建失败: ',
'当前对象不支持测试发送消息',
'(已提交 ',
'测试消息已发送到 ',
"destination || '目标'",
].forEach((legacyCopy) => {
expect(objectActionsSource).not.toContain(legacyCopy);
});
[
"'释放连接失败'",
'连接已从侧边栏断开,但后端连接释放失败',
].forEach((legacyCopy) => {
expect(v2ActionHandlersSource).not.toContain(legacyCopy);
});
[
"label: '刷新'",
"label: '新建表'",
"label: '按名称排序'",
@@ -49,19 +63,44 @@ describe('Sidebar residual actions i18n', () => {
'确定要删除标签',
"label: '绑定到连接'",
'删除查询失败: ',
'数据库创建成功',
'创建失败: ',
'aria-label={`切换到连接 ${conn.name}`}',
].forEach((legacyCopy) => {
expect(source).not.toContain(legacyCopy);
expect(legacyMenuSource).not.toContain(legacyCopy);
});
requiredKeys.forEach((key) => {
expect(source).toContain(`t('${key}'`);
[
'sidebar.message.saved_query_rename_failed',
'sidebar.message.saved_query_rebind_success',
'sidebar.message.saved_query_rebind_failed',
'sidebar.message.database_created',
'sidebar.message.operation_create_failed',
'sidebar.message.message_publish_unsupported',
'sidebar.message.message_publish_success',
'sidebar.message.message_publish_success_with_count',
'sidebar.message.message_publish_target_fallback',
].forEach((key) => {
expect(objectActionsSource).toContain(`t('${key}'`);
});
expect(source).toContain("label: conn.name || conn.id");
expect(source).toContain('node.title');
[
'sidebar.message.connection_release_failed_from_sidebar',
'sidebar.menu.new_table',
'sidebar.menu.create_event',
'sidebar.tab.new_event',
'sidebar.modal.confirm_delete_tag.content',
'sidebar.menu.bind_to_connection',
'sidebar.message.saved_query_delete_failed',
].forEach((key) => {
expect(`${legacyMenuSource}\n${v2ActionHandlersSource}`).toContain(`t('${key}'`);
});
[
'sidebar.message.connection_release_failed_from_sidebar',
].forEach((key) => {
expect(v2ActionHandlersSource).toContain(`t('${key}'`);
});
expect(legacyMenuSource).toContain("label: conn.name || conn.id");
expect(legacyMenuSource).toContain('node.title');
});
it('keeps residual Sidebar keys available in every locale', () => {

View File

@@ -579,7 +579,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
connectionId: tab.connectionId,
dbName: tab.dbName,
tableName,
title: `导出 ${tableName}`,
title: t('file.backend.dialog.export_table', { table: tableName }),
objectType: 'table',
rowCountByScope: Number.isFinite(Number(totalRows)) && Number(totalRows) > 0
? { all: Math.trunc(Number(totalRows)) }

View File

@@ -1164,9 +1164,14 @@ export const getUriPlaceholder = (dbType: string) => {
return "http://127.0.0.1:6333";
}
if (dbType === "iotdb") {
return "iotdb://root:root@127.0.0.1:6667/root.sg";
}
if (dbType === "rocketmq") {
return "iotdb://root:root@127.0.0.1:6667/root.sg";
}
if (dbType === "rocketmq") {
return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";
}
if (dbType === "mqtt") {
return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";
}
if (dbType === "kafka") {
return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";
}

View File

@@ -19,6 +19,7 @@ import {
opTypeColor,
formatNumber,
} from '../../utils/explainTypes'
import { useI18n } from '../../i18n/provider'
// 执行计划图主组件。
// 使用 react-flow 渲染扁平节点数组dagre 自动计算树形布局。
@@ -144,6 +145,7 @@ const ExplainGraphNodeRenderer = memo(function ExplainGraphNodeRenderer({
}: {
data: ExplainGraphNodeData
}) {
const { t } = useI18n()
const { node, isSelected } = data
const color = opTypeColor(node.opType)
const hasFullScan = node.flags?.includes('FULL_SCAN')
@@ -168,38 +170,38 @@ const ExplainGraphNodeRenderer = memo(function ExplainGraphNodeRenderer({
<div style={{ fontWeight: 600, color, marginBottom: 4 }}>{node.opDetail || node.opType}</div>
{node.table && (
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
<span style={{ opacity: 0.6 }}></span>
<span style={{ opacity: 0.6 }}>{t('sql_analysis.explain_graph.label.table')}</span>
<code style={{ fontSize: 11 }}>{node.table}</code>
</div>
)}
{node.index && (
<div style={{ color: 'var(--gn-text-muted, #495057)', marginBottom: 2 }}>
<span style={{ opacity: 0.6 }}></span>
<span style={{ opacity: 0.6 }}>{t('sql_analysis.explain_graph.label.index')}</span>
<code style={{ fontSize: 11 }}>{node.index}</code>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 4, flexWrap: 'wrap' }}>
{node.estRows !== undefined && node.estRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
<strong>{formatNumber(node.estRows)}</strong>
{t('sql_analysis.explain_graph.metric.est_rows')} <strong>{formatNumber(node.estRows)}</strong>
</span>
)}
{node.actualRows !== undefined && node.actualRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
<strong>{formatNumber(node.actualRows)}</strong>
{t('sql_analysis.explain_graph.metric.actual_rows')} <strong>{formatNumber(node.actualRows)}</strong>
</span>
)}
{node.cost !== undefined && node.cost > 0 && (
<span style={{ color: 'var(--gn-text-muted, #495057)' }}>
<strong>{node.cost.toFixed(1)}</strong>
{t('sql_analysis.explain_graph.metric.cost')} <strong>{node.cost.toFixed(1)}</strong>
</span>
)}
</div>
{(hasFullScan || hasFilesort || hasTempTable) && (
<div style={{ display: 'flex', gap: 4, marginTop: 6, flexWrap: 'wrap' }}>
{hasFullScan && <FlagBadge color="#fa5252" text="全表扫描" />}
{hasFilesort && <FlagBadge color="#f08c00" text="额外排序" />}
{hasTempTable && <FlagBadge color="#7048e8" text="临时表" />}
{hasFullScan && <FlagBadge color="#fa5252" text={t('sql_analysis.explain_graph.flag.full_scan')} />}
{hasFilesort && <FlagBadge color="#f08c00" text={t('sql_analysis.explain_graph.flag.filesort')} />}
{hasTempTable && <FlagBadge color="#7048e8" text={t('sql_analysis.explain_graph.flag.temp_table')} />}
</div>
)}
</div>

View File

@@ -9,6 +9,7 @@ import {
formatPercent,
formatMs,
} from '../../utils/explainTypes'
import { useI18n } from '../../i18n/provider'
// 诊断侧栏:节点详情 + 统计条 + 索引建议列表的合集组件。
// 拆分为一个文件减少模块碎片化plan 原拆 3 个文件)。
@@ -50,12 +51,13 @@ function ExplainStatsBar({
stats: ExplainStats
warnings?: string[]
}) {
const { t } = useI18n()
const statsList = [
{ label: '总成本', value: stats.totalCost ? stats.totalCost.toFixed(1) : '-' },
{ label: '总耗时', value: formatMs(stats.totalDurationMs) },
{ label: '扫描行数', value: formatNumber(stats.rowsRead) },
{ label: '缓冲命中', value: formatPercent(stats.bufferHitRate) },
{ label: '最大单节点行数', value: formatNumber(stats.maxEstRows) },
{ label: t('sql_analysis.sidebar.stats.total_cost'), value: stats.totalCost ? stats.totalCost.toFixed(1) : '-' },
{ label: t('sql_analysis.sidebar.stats.total_duration'), value: formatMs(stats.totalDurationMs) },
{ label: t('sql_analysis.sidebar.stats.rows_read'), value: formatNumber(stats.rowsRead) },
{ label: t('sql_analysis.sidebar.stats.buffer_hit'), value: formatPercent(stats.bufferHitRate) },
{ label: t('sql_analysis.sidebar.stats.max_est_rows'), value: formatNumber(stats.maxEstRows) },
]
return (
<div
@@ -66,7 +68,7 @@ function ExplainStatsBar({
padding: 10,
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}></div>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>{t('sql_analysis.sidebar.stats.title')}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px', fontSize: 12 }}>
{statsList.map((s) => (
<div key={s.label} style={{ display: 'flex', justifyContent: 'space-between' }}>
@@ -75,9 +77,9 @@ function ExplainStatsBar({
</div>
))}
</div>
{stats.hasFullScan && <WarningRow color="#fa5252" text="存在全表扫描" />}
{stats.hasFilesort && <WarningRow color="#f08c00" text="存在额外排序" />}
{stats.hasTempTable && <WarningRow color="#7048e8" text="使用临时表" />}
{stats.hasFullScan && <WarningRow color="#fa5252" text={t('sql_analysis.sidebar.warning.full_scan')} />}
{stats.hasFilesort && <WarningRow color="#f08c00" text={t('sql_analysis.sidebar.warning.filesort')} />}
{stats.hasTempTable && <WarningRow color="#7048e8" text={t('sql_analysis.sidebar.warning.temp_table')} />}
{warnings && warnings.length > 0 && (
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--gn-text-muted, #6c757d)' }}>
{warnings.map((w, i) => (
@@ -99,19 +101,20 @@ function WarningRow({ color, text }: { color: string; text: string }) {
}
function ExplainNodeDetail({ node }: { node: ExplainNode }) {
const { t } = useI18n()
const rows: Array<[string, string]> = []
rows.push(['操作类型', node.opType])
if (node.opDetail) rows.push(['操作详情', node.opDetail])
if (node.table) rows.push(['表', node.table])
if (node.index) rows.push(['索引', node.index])
if (node.estRows) rows.push(['估算行数', formatNumber(node.estRows)])
if (node.actualRows) rows.push(['实际行数', formatNumber(node.actualRows)])
if (node.loops) rows.push(['循环次数', formatNumber(node.loops)])
if (node.cost) rows.push(['成本', node.cost.toFixed(2)])
if (node.durationMs) rows.push(['耗时', formatMs(node.durationMs)])
rows.push([t('sql_analysis.sidebar.node.op_type'), node.opType])
if (node.opDetail) rows.push([t('sql_analysis.sidebar.node.op_detail'), node.opDetail])
if (node.table) rows.push([t('sql_analysis.sidebar.node.table'), node.table])
if (node.index) rows.push([t('sql_analysis.sidebar.node.index'), node.index])
if (node.estRows) rows.push([t('sql_analysis.sidebar.node.est_rows'), formatNumber(node.estRows)])
if (node.actualRows) rows.push([t('sql_analysis.sidebar.node.actual_rows'), formatNumber(node.actualRows)])
if (node.loops) rows.push([t('sql_analysis.sidebar.node.loops'), formatNumber(node.loops)])
if (node.cost) rows.push([t('sql_analysis.sidebar.node.cost'), node.cost.toFixed(2)])
if (node.durationMs) rows.push([t('sql_analysis.sidebar.node.duration'), formatMs(node.durationMs)])
if (node.bufferHit !== undefined && node.bufferHit > 0)
rows.push(['缓冲命中', formatPercent(node.bufferHit)])
if (node.flags && node.flags.length > 0) rows.push(['标志', node.flags.join(', ')])
rows.push([t('sql_analysis.sidebar.node.buffer_hit'), formatPercent(node.bufferHit)])
if (node.flags && node.flags.length > 0) rows.push([t('sql_analysis.sidebar.node.flags'), node.flags.join(', ')])
return (
<div
@@ -122,7 +125,7 @@ function ExplainNodeDetail({ node }: { node: ExplainNode }) {
padding: 10,
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}></div>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>{t('sql_analysis.sidebar.node.title')}</div>
<div style={{ fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
{rows.map(([label, value]) => (
<div key={label} style={{ display: 'flex', gap: 8 }}>
@@ -134,7 +137,7 @@ function ExplainNodeDetail({ node }: { node: ExplainNode }) {
{node.extra && Object.keys(node.extra).length > 0 && (
<details style={{ marginTop: 8, fontSize: 11 }}>
<summary style={{ cursor: 'pointer', color: 'var(--gn-text-muted, #6c757d)' }}>
Extra {Object.keys(node.extra).length}
{t('sql_analysis.sidebar.node.extra', { count: Object.keys(node.extra).length })}
</summary>
<pre style={{ marginTop: 4, fontSize: 11, maxHeight: 120, overflow: 'auto' }}>
{JSON.stringify(node.extra, null, 2)}
@@ -152,6 +155,7 @@ function IndexSuggestionList({
suggestions: IndexSuggestion[]
onSelect?: (s: IndexSuggestion) => void
}) {
const { t } = useI18n()
return (
<div
style={{
@@ -164,11 +168,11 @@ function IndexSuggestionList({
}}
>
<div style={{ fontWeight: 600, marginBottom: 8, fontSize: 13 }}>
{suggestions.length}
{t('sql_analysis.sidebar.suggestions.title', { count: suggestions.length })}
</div>
{suggestions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--gn-text-muted, #6c757d)', padding: '20px 0', textAlign: 'center' }}>
{t('sql_analysis.sidebar.suggestions.empty')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -188,6 +192,7 @@ function SuggestionCard({
suggestion: IndexSuggestion
onSelect?: (s: IndexSuggestion) => void
}) {
const { t } = useI18n()
const color = severityColor(suggestion.severity)
return (
<div
@@ -206,7 +211,7 @@ function SuggestionCard({
</span>
{suggestion.estRows !== undefined && suggestion.estRows > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)', fontSize: 11 }}>
{formatNumber(suggestion.estRows)}
{t('sql_analysis.sidebar.suggestions.rows', { count: formatNumber(suggestion.estRows) })}
</span>
)}
</div>
@@ -226,7 +231,8 @@ function SuggestionCard({
)}
{suggestion.affectedTable && (
<div style={{ marginTop: 4, fontSize: 11, color: 'var(--gn-text-muted, #6c757d)' }}>
<code>{suggestion.affectedTable}</code>
{t('sql_analysis.sidebar.suggestions.table', { table: suggestion.affectedTable }).replace(suggestion.affectedTable, '')}
<code>{suggestion.affectedTable}</code>
</div>
)}
</div>

View File

@@ -3,6 +3,7 @@ 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 { useI18n } from '../../i18n/provider'
import type { ConnectionConfig } from '../../types'
import type { DiagnoseReport, ExplainNode, IndexSuggestion } from '../../utils/explainTypes'
import ExplainGraph from './ExplainGraph'
@@ -40,6 +41,7 @@ interface ExplainReportViewProps {
}
export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReportViewProps) {
const { t } = useI18n()
const [loading, setLoading] = useState(false)
const [report, setReport] = useState<DiagnoseReport | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -49,7 +51,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
const runDiagnose = useCallback(async () => {
if (!sql.trim()) {
setError('查询语句为空')
setError(t('sql_analysis.explain.error.query_required'))
return
}
setLoading(true)
@@ -59,7 +61,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
try {
const result = await DiagnoseQuery(buildRpcConnectionConfig(config), dbName, sql)
if (!result.success) {
setError(result.message || '诊断失败')
setError(result.message || t('sql_analysis.explain.error.run_failed'))
} else {
const data = result.data as DiagnoseReport
setReport(data)
@@ -69,7 +71,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
} finally {
setLoading(false)
}
}, [config, dbName, sql])
}, [config, dbName, sql, t])
useEffect(() => {
if (!hasRequestedRun) {
@@ -100,17 +102,17 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
<style>{reportViewStyles}</style>
{loading && (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在执行 EXPLAIN 并解析计划..." />
<Spin tip={t('sql_analysis.explain.loading')} />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
<Text strong>{t('sql_analysis.explain.error.title')}</Text>
{error}
</Paragraph>
)}
{!loading && !error && !report && !hasRequestedRun && (
<Empty description="输入 SQL 后运行诊断" style={{ padding: '48px 0' }} />
<Empty description={t('sql_analysis.explain.empty')} style={{ padding: '48px 0' }} />
)}
{!loading && !error && report && (
<div className="gn-explain-report-shell">
@@ -125,7 +127,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
label: (
<span className="gn-explain-report-switcher-label">
<ApartmentOutlined />
<span></span>
<span>{t('sql_analysis.explain.view.plan')}</span>
</span>
),
},
@@ -134,14 +136,14 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
label: (
<span className="gn-explain-report-switcher-label">
<CodeOutlined />
<span></span>
<span>{t('sql_analysis.explain.view.raw')}</span>
</span>
),
},
]}
/>
<Text type="secondary" className="gn-explain-report-switcher-meta">
{report.plan.nodes.length}
{t('sql_analysis.explain.meta.node_count', { count: report.plan.nodes.length })}
<span className="gn-explain-report-switcher-meta-separator">/</span>
{report.plan.rawFormat}
</Text>
@@ -184,7 +186,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
boxSizing: 'border-box',
}}
>
{report.plan.rawPayload || '(无原文)'}
{report.plan.rawPayload || t('sql_analysis.explain.raw.empty')}
</pre>
)}
</div>
@@ -195,6 +197,7 @@ export function ExplainReportView({ config, dbName, sql, runKey }: ExplainReport
}
export default function ExplainWorkbench({ open, onClose, config, dbName, sql }: ExplainWorkbenchProps) {
const { t } = useI18n()
return (
<Modal
open={open}
@@ -202,7 +205,7 @@ export default function ExplainWorkbench({ open, onClose, config, dbName, sql }:
footer={null}
width="90%"
style={{ top: 20 }}
title={<Title level={5} style={{ margin: 0 }}>SQL </Title>}
title={<Title level={5} style={{ margin: 0 }}>{t('sql_analysis.workbench.title')}</Title>}
destroyOnClose
>
<div style={{ minHeight: 480, height: '70vh' }}>

View File

@@ -3,6 +3,7 @@ import { Button, Empty, Modal, Segmented, Spin, Tooltip, Typography, message } f
import { ReloadOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons'
import { ClearSlowQueries, GetSlowQueries } from '../../../wailsjs/go/app/App'
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'
import { useI18n } from '../../i18n/provider'
import type { ConnectionConfig } from '../../types'
import { formatMs, formatNumber } from '../../utils/explainTypes'
@@ -53,6 +54,7 @@ export function SlowQueryPanelContent({
onPickQuery,
activeToken,
}: SlowQueryPanelContentProps) {
const { t } = useI18n()
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState<SlowQueryRecord[]>([])
const [error, setError] = useState<string | null>(null)
@@ -64,7 +66,7 @@ export function SlowQueryPanelContent({
try {
const result = await GetSlowQueries(buildRpcConnectionConfig(config), dbName, sortBy, 100)
if (!result.success) {
setError(result.message || '加载失败')
setError(result.message || t('sql_analysis.slow_query.error.load_failed'))
setRecords([])
} else {
setRecords((result.data as SlowQueryRecord[]) ?? [])
@@ -74,7 +76,7 @@ export function SlowQueryPanelContent({
} finally {
setLoading(false)
}
}, [config, dbName, sortBy])
}, [config, dbName, sortBy, t])
useEffect(() => {
if (activeToken === null || activeToken === undefined || activeToken === '') {
@@ -86,12 +88,12 @@ export function SlowQueryPanelContent({
const handleClear = useCallback(async () => {
const result = await ClearSlowQueries(buildRpcConnectionConfig(config), dbName)
if (result.success) {
message.success('已清空慢查询历史')
message.success(t('sql_analysis.slow_query.message.cleared'))
setRecords([])
} else {
message.error(result.message || '清空失败')
message.error(result.message || t('sql_analysis.slow_query.error.clear_failed'))
}
}, [config, dbName])
}, [config, dbName, t])
const handlePick = useCallback(
(record: SlowQueryRecord) => {
@@ -111,16 +113,16 @@ export function SlowQueryPanelContent({
value={sortBy}
onChange={(v) => setSortBy(v as SortBy)}
options={[
{ label: '按耗时', value: 'duration' },
{ label: '按扫描行数', value: 'rowsRead' },
{ label: '按时间', value: 'recent' },
{ label: t('sql_analysis.slow_query.sort.duration'), value: 'duration' },
{ label: t('sql_analysis.slow_query.sort.rows_read'), value: 'rowsRead' },
{ label: t('sql_analysis.slow_query.sort.recent'), value: 'recent' },
]}
/>
<div style={{ display: 'flex', gap: 8 }}>
<Tooltip title="刷新">
<Tooltip title={t('common.refresh')}>
<Button icon={<ReloadOutlined />} onClick={() => void reload()} loading={loading} />
</Tooltip>
<Tooltip title="清空当前连接的历史">
<Tooltip title={t('sql_analysis.slow_query.tooltip.clear_current')}>
<Button danger icon={<DeleteOutlined />} onClick={() => void handleClear()} disabled={records.length === 0} />
</Tooltip>
</div>
@@ -128,19 +130,19 @@ export function SlowQueryPanelContent({
{loading && (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin tip="加载慢查询历史..." />
<Spin tip={t('sql_analysis.slow_query.loading')} />
</div>
)}
{error && (
<Paragraph type="danger" style={{ padding: 16 }}>
<Text strong></Text>
<Text strong>{t('sql_analysis.slow_query.error.title')}</Text>
{error}
</Paragraph>
)}
{!loading && !error && sorted.length === 0 && (
<Empty description="暂无慢查询记录(阈值 500ms" style={{ padding: '40px 0' }} />
<Empty description={t('sql_analysis.slow_query.empty', { threshold: 500 })} style={{ padding: '40px 0' }} />
)}
{!loading && !error && sorted.length > 0 && (
@@ -155,6 +157,7 @@ export function SlowQueryPanelContent({
}
export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQuery }: SlowQueryPanelProps) {
const { t } = useI18n()
return (
<Modal
open={open}
@@ -165,9 +168,9 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ThunderboltOutlined style={{ color: '#fa5252' }} />
<Title level={5} style={{ margin: 0 }}> SQL </Title>
<Title level={5} style={{ margin: 0 }}>{t('sql_analysis.slow_query.title')}</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{dbName || '(当前连接)'}
{dbName || t('sql_analysis.slow_query.current_connection')}
</Text>
</div>
}
@@ -189,6 +192,7 @@ export default function SlowQueryPanel({ open, onClose, config, dbName, onPickQu
}
function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: () => void }) {
const { t } = useI18n()
const duration = record.durationMs ?? 0
const durationColor = duration >= 5000 ? '#fa5252' : duration >= 1000 ? '#f08c00' : '#495057'
@@ -209,18 +213,18 @@ function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: ()
<span style={{ color: durationColor, fontWeight: 600 }}>{formatMs(duration)}</span>
{record.rowsRead !== undefined && record.rowsRead > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
<strong>{formatNumber(record.rowsRead)}</strong>
{t('sql_analysis.slow_query.metric.rows_read')} <strong>{formatNumber(record.rowsRead)}</strong>
</span>
)}
{record.rowsReturned !== undefined && record.rowsReturned > 0 && (
<span style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
<strong>{formatNumber(record.rowsReturned)}</strong>
{t('sql_analysis.slow_query.metric.rows_returned')} <strong>{formatNumber(record.rowsReturned)}</strong>
</span>
)}
</div>
<div style={{ color: 'var(--gn-text-muted, #6c757d)' }}>
{record.dbType && <code style={{ marginRight: 8 }}>{record.dbType}</code>}
{record.executedAt && formatRelativeTime(record.executedAt)}
{record.executedAt && formatRelativeTime(record.executedAt, t)}
</div>
</div>
<pre
@@ -234,19 +238,26 @@ function SlowQueryCard({ record, onPick }: { record: SlowQueryRecord; onPick: ()
overflow: 'hidden',
}}
>
{record.sqlPreview || '(无 SQL 预览)'}
{record.sqlPreview || t('sql_analysis.slow_query.preview.empty')}
</pre>
</div>
)
}
// formatRelativeTime 把 ISO 时间字符串格式化为相对时间("3分钟前")。
function formatRelativeTime(isoTime: string): string {
function formatRelativeTime(
isoTime: string,
t: ReturnType<typeof useI18n>['t'],
): string {
const ts = Date.parse(isoTime)
if (isNaN(ts)) return ''
const diffMs = Date.now() - ts
if (diffMs < 60_000) return '刚刚'
if (diffMs < 3600_000) return `${Math.floor(diffMs / 60_000)} 分钟前`
if (diffMs < 86400_000) return `${Math.floor(diffMs / 3600_000)} 小时前`
return `${Math.floor(diffMs / 86400_000)} 天前`
if (diffMs < 60_000) return t('sql_analysis.slow_query.relative.just_now')
if (diffMs < 3600_000) {
return t('sql_analysis.slow_query.relative.minutes_ago', { count: Math.floor(diffMs / 60_000) })
}
if (diffMs < 86400_000) {
return t('sql_analysis.slow_query.relative.hours_ago', { count: Math.floor(diffMs / 3600_000) })
}
return t('sql_analysis.slow_query.relative.days_ago', { count: Math.floor(diffMs / 86400_000) })
}

View File

@@ -3,6 +3,7 @@ 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 { useI18n } from '../../i18n/provider'
import { ExplainReportView } from './ExplainWorkbench'
import { SlowQueryPanelContent } from './SlowQueryPanel'
@@ -23,6 +24,7 @@ const normalizeConnectionConfig = (connection: any): ConnectionConfig => ({
})
export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
const { t } = useI18n()
const connections = useStore((state) => state.connections)
const connection = useMemo(
() => connections.find((item) => item.id === tab.connectionId) || null,
@@ -51,12 +53,12 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
const triggerDiagnose = useCallback(() => {
if (!sqlDraft.trim()) {
message.warning('请输入要诊断的 SQL')
message.warning(t('sql_analysis.workbench.validation.sql_required'))
return
}
setActiveView('diagnose')
setDiagnoseRunKey((previous) => previous + 1)
}, [sqlDraft])
}, [sqlDraft, t])
const handlePickSlowQuery = useCallback((sql: string) => {
const nextSql = String(sql || '')
@@ -83,8 +85,8 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
<Alert
type="warning"
showIcon
message="当前工作台对应的连接已不可用"
description="请重新选择一个有效连接后再打开 SQL 分析工作台。"
message={t('sql_analysis.workbench.alert.connection_missing_title')}
description={t('sql_analysis.workbench.alert.connection_missing_description')}
/>
</div>
)
@@ -96,7 +98,7 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
<div className="gn-sql-analysis-workbench-header">
<div className="gn-sql-analysis-workbench-header-main">
<Title level={5} style={{ margin: 0 }}>
SQL
{t('sql_analysis.workbench.title')}
</Title>
<Text type="secondary">
{connection?.name || tab.connectionId}
@@ -113,7 +115,7 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
label: (
<span className="gn-sql-analysis-view-switcher-label">
<HistoryOutlined />
<span> SQL</span>
<span>{t('sql_analysis.workbench.view.slow_query')}</span>
</span>
),
},
@@ -122,7 +124,7 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
label: (
<span className="gn-sql-analysis-view-switcher-label">
<SearchOutlined />
<span>SQL </span>
<span>{t('sql_analysis.workbench.view.diagnose')}</span>
</span>
),
},
@@ -146,13 +148,13 @@ export default function SqlAnalysisWorkbench({ tab }: { tab: TabData }) {
<Input.TextArea
value={sqlDraft}
onChange={(event) => setSqlDraft(event.target.value)}
placeholder="输入要诊断的 SQL或从慢 SQL 列表点击条目带入"
placeholder={t('sql_analysis.workbench.editor.placeholder')}
autoSize={{ minRows: 5, maxRows: 10 }}
/>
<div className="gn-sql-analysis-editor-actions">
<Text type="secondary"> SQL </Text>
<Text type="secondary">{t('sql_analysis.workbench.editor.hint')}</Text>
<Button type="primary" icon={<SearchOutlined />} onClick={triggerDiagnose}>
{t('sql_analysis.workbench.action.run')}
</Button>
</div>
</div>

View File

@@ -11,6 +11,7 @@ import {
} from '@ant-design/icons';
import Modal from '../common/ResizableDraggableModal';
import type { SavedConnection } from '../../types';
import { t } from '../../i18n';
import type {
BatchObjectFilterType,
BatchSelectionScope,
@@ -126,7 +127,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Button key="cancel" onClick={() => setIsBatchModalOpen(false)}>
{t('sidebar.action.cancel')}
</Button>
<Space size={8} wrap style={{ marginLeft: 'auto' }}>
<Button
@@ -136,7 +137,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleBatchClear()}
disabled={checkedTableKeys.length === 0}
>
{t('sidebar.action.clear_tables')}
</Button>
<Button
key="export-schema"
@@ -144,7 +145,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleBatchExport('schema')}
disabled={checkedTableKeys.length === 0}
>
{t('sidebar.action.export_schema')}
</Button>
<Button
key="export-data-only"
@@ -152,7 +153,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleBatchExport('dataOnly')}
disabled={checkedTableKeys.length === 0}
>
(INSERT)
{t('sidebar.action.export_data_only')}
</Button>
<Button
key="backup"
@@ -161,7 +162,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleBatchExport('backup')}
disabled={checkedTableKeys.length === 0}
>
(+)
{t('sidebar.action.backup_schema_data')}
</Button>
</Space>
</div>
@@ -169,12 +170,14 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
>
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}></label>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>
{t('sidebar.field.select_connection')}
</label>
<Select
value={selectedConnection}
onChange={handleConnectionChange}
style={{ width: '100%' }}
placeholder="请选择连接"
placeholder={t('sidebar.placeholder.select_connection')}
>
{nonRedisConnections(connections).map(conn => (
<Select.Option key={conn.id} value={conn.id}>
@@ -184,12 +187,14 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
</Select>
</div>
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}></label>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 500 }}>
{t('sidebar.field.select_database')}
</label>
<Select
value={selectedDatabase}
onChange={handleDatabaseChange}
style={{ width: '100%' }}
placeholder="请先选择连接"
placeholder={t('sidebar.placeholder.select_connection_first')}
disabled={!selectedConnection}
>
{availableDatabases.map(db => (
@@ -199,7 +204,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
))}
</Select>
</div>
<div style={modalHintTextStyle}></div>
<div style={modalHintTextStyle}>{t('sidebar.modal.batch_tables.selection_hint')}</div>
</div>
{batchTables.length > 0 && (
@@ -209,7 +214,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
allowClear
value={batchFilterKeyword}
onChange={(e) => setBatchFilterKeyword(e.target.value)}
placeholder="筛选表/视图名称"
placeholder={t('sidebar.placeholder.filter_table_view')}
prefix={<SearchOutlined />}
style={{ width: 260 }}
/>
@@ -218,9 +223,9 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)}
style={{ width: 140 }}
options={[
{ label: '全部对象', value: 'all' },
{ label: '仅表', value: 'table' },
{ label: '仅视图', value: 'view' },
{ label: t('sidebar.filter.all_objects'), value: 'all' },
{ label: t('sidebar.filter.tables_only'), value: 'table' },
{ label: t('sidebar.filter.views_only'), value: 'view' },
]}
/>
<Select
@@ -228,13 +233,16 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onChange={(value) => setBatchSelectionScope(value as BatchSelectionScope)}
style={{ width: 220 }}
options={[
{ label: '勾选作用于:当前筛选结果', value: 'filtered' },
{ label: '勾选作用于:全部对象', value: 'all' },
{ label: t('sidebar.filter.scope_filtered'), value: 'filtered' },
{ label: t('sidebar.filter.scope_all'), value: 'all' },
]}
/>
</Space>
<div style={{ marginTop: 6, color: '#999', fontSize: 12 }}>
{filteredBatchObjects.length} / {batchTables.length}
{t('sidebar.batch.filtered_count', {
filtered: filteredBatchObjects.length,
total: batchTables.length,
})}
</div>
</div>
)}
@@ -248,24 +256,27 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleCheckAll(true)}
disabled={selectionScopeTargetKeys.length === 0}
>
{t('sidebar.action.select_all')}
</Button>
<Button
size="small"
onClick={() => handleCheckAll(false)}
disabled={selectionScopeTargetKeys.length === 0}
>
{t('sidebar.action.clear_selection')}
</Button>
<Button
size="small"
onClick={handleInvertSelection}
disabled={selectionScopeTargetKeys.length === 0}
>
{t('sidebar.action.invert_selection')}
</Button>
<span style={{ color: '#999' }}>
{checkedTableKeys.length} / {batchTables.length}
{t('sidebar.batch.selected_objects', {
selected: checkedTableKeys.length,
total: batchTables.length,
})}
</span>
</Space>
</div>
@@ -279,7 +290,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
{groupedBatchObjects.tables.length > 0 && (
<div>
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
({groupedBatchObjects.tables.length})
{t('sidebar.batch.group.tables')} ({groupedBatchObjects.tables.length})
</div>
<Space direction="vertical" style={{ width: '100%' }}>
{groupedBatchObjects.tables.map(table => (
@@ -294,7 +305,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
{groupedBatchObjects.views.length > 0 && (
<div>
<div style={{ marginBottom: 6, color: darkMode ? '#bfbfbf' : '#595959', fontSize: 12 }}>
({groupedBatchObjects.views.length})
{t('sidebar.batch.group.views')} ({groupedBatchObjects.views.length})
</div>
<Space direction="vertical" style={{ width: '100%' }}>
{groupedBatchObjects.views.map(view => (
@@ -308,7 +319,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
)}
{groupedBatchObjects.tables.length === 0 && groupedBatchObjects.views.length === 0 && (
<div style={{ color: '#999', padding: '8px 0' }}>
{t('sidebar.batch.no_matching_objects')}
</div>
)}
</div>
@@ -327,7 +338,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }}
footer={[
<Button key="cancel" onClick={() => setIsBatchDbModalOpen(false)}>
{t('sidebar.action.cancel')}
</Button>,
<Button
key="export-schema"
@@ -335,7 +346,7 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleBatchDbExport(false)}
disabled={checkedDbKeys.length === 0}
>
({checkedDbKeys.length})
{t('sidebar.action.export_database_schema_count', { count: checkedDbKeys.length })}
</Button>,
<Button
key="backup"
@@ -344,17 +355,19 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
onClick={() => handleBatchDbExport(true)}
disabled={checkedDbKeys.length === 0}
>
({checkedDbKeys.length})
{t('sidebar.action.backup_database_count', { count: checkedDbKeys.length })}
</Button>,
]}
>
<div style={{ ...modalSectionStyle, marginBottom: 16 }}>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}></label>
<label style={{ display: 'block', marginBottom: 4, fontWeight: 600, color: darkMode ? '#f5f7ff' : '#162033' }}>
{t('sidebar.field.select_connection')}
</label>
<Select
value={selectedDbConnection}
onChange={handleDbConnectionChange}
style={{ width: '100%' }}
placeholder="请选择连接"
placeholder={t('sidebar.placeholder.select_connection')}
>
{nonRedisConnections(connections).map(conn => (
<Select.Option key={conn.id} value={conn.id}>
@@ -362,7 +375,9 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
</Select.Option>
))}
</Select>
<div style={{ ...modalHintTextStyle, marginTop: 10 }}></div>
<div style={{ ...modalHintTextStyle, marginTop: 10 }}>
{t('sidebar.modal.batch_databases.selection_hint')}
</div>
</div>
{batchDatabases.length > 0 && (
@@ -373,22 +388,25 @@ export const SidebarBatchExportModals: React.FC<SidebarBatchExportModalsProps> =
size="small"
onClick={() => handleCheckAllDb(true)}
>
{t('sidebar.action.select_all')}
</Button>
<Button
size="small"
onClick={() => handleCheckAllDb(false)}
>
{t('sidebar.action.clear_selection')}
</Button>
<Button
size="small"
onClick={handleInvertSelectionDb}
>
{t('sidebar.action.invert_selection')}
</Button>
<span style={{ color: '#999' }}>
{checkedDbKeys.length} / {batchDatabases.length}
{t('sidebar.batch.selected_databases', {
selected: checkedDbKeys.length,
total: batchDatabases.length,
})}
</span>
</Space>
</div>

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { Tooltip } from 'antd'
import { HistoryOutlined } from '@ant-design/icons'
import { useStore } from '../../store'
import { useI18n } from '../../i18n/provider'
import { buildSqlAnalysisWorkbenchTab } from '../../utils/sqlAnalysisTab'
// Sidebar 底部的慢 SQL 工作台入口。
@@ -40,6 +41,7 @@ export default function SlowQueryRailButton({
style,
tooltipPlacement = 'right',
}: SlowQueryRailButtonProps) {
const { t } = useI18n()
const tabs = useStore(s => s.tabs)
const activeTabId = useStore(s => s.activeTabId)
const connections = useStore(s => s.connections)
@@ -60,8 +62,8 @@ export default function SlowQueryRailButton({
const buttonDisabled = !hasActiveConnection
const tooltipText = buttonDisabled
? '请先打开一个数据库连接的标签页'
: '打开当前连接的 SQL 分析工作台'
? t('sql_analysis.slow_query.rail.tooltip.no_connection')
: t('sql_analysis.slow_query.rail.tooltip.open')
return (
<Tooltip title={tooltipText} placement={tooltipPlacement}>
@@ -95,7 +97,7 @@ export default function SlowQueryRailButton({
transition: 'opacity 0.15s, color 0.15s',
...style,
}}
aria-label="慢 SQL 工作台"
aria-label={t('sql_analysis.slow_query.rail.aria_label')}
>
<HistoryOutlined style={{ fontSize: 16 }} />
</button>

View File

@@ -251,7 +251,9 @@ export const useSidebarBatchExport = ({
addTab(buildBatchTableExportWorkbenchTab({
connectionId: connId,
dbName: dbName || undefined,
title: dbName ? `批量导出 ${dbName} 对象` : '批量导出对象',
title: dbName
? t('sidebar.tab.batch_export_objects_database', { database: dbName })
: t('sidebar.tab.batch_export_objects'),
}));
};
@@ -592,7 +594,7 @@ export const useSidebarBatchExport = ({
addTab(buildBatchDatabaseExportWorkbenchTab({
connectionId: connId,
title: '批量导出库',
title: t('sidebar.tab.batch_export_databases'),
}));
};

View File

@@ -198,7 +198,7 @@ export const useSidebarObjectActions = ({
const rowCount = Number(node?.dataRef?.rowCount);
const totalRowsKnown = Number.isFinite(rowCount) && rowCount > 0;
await runExportWithProgress({
title: `导出 ${tableName}`,
title: t('file.backend.dialog.export_table', { table: tableName }),
targetName: tableName,
format: options.format,
totalRows: totalRowsKnown ? rowCount : undefined,
@@ -219,7 +219,7 @@ export const useSidebarObjectActions = ({
const openExportDialog = async (node: any) => {
const tableName = String(node?.dataRef?.tableName || node?.title || '').trim();
if (!tableName) {
message.warning('未识别到表名,无法导出');
message.warning(t('sidebar.message.table_export_target_missing'));
return;
}
const connectionId = resolveSidebarNodeConnectionId(node, connectionIds) || String(node?.dataRef?.id || '').trim();
@@ -228,7 +228,7 @@ export const useSidebarObjectActions = ({
connectionId,
dbName,
tableName,
title: `导出 ${tableName}`,
title: t('file.backend.dialog.export_table', { table: tableName }),
objectType: node?.type === 'view' ? 'view' : (node?.type === 'materialized-view' ? 'materialized-view' : 'table'),
schemaName: typeof node?.dataRef?.schemaName === 'string' ? node.dataRef.schemaName : undefined,
sidebarLocateKey: typeof node?.key === 'string' ? node.key : undefined,
@@ -264,9 +264,10 @@ export const useSidebarObjectActions = ({
const conn = node.dataRef;
const tableName = String(conn?.tableName || node?.title || '').trim();
if (!conn?.id || !conn?.dbName || !tableName) {
message.warning('当前表缺少连接上下文,无法发送给 AI');
message.warning(t('sidebar.message.ai_table_context_missing'));
return;
}
const tableRef = `${conn.dbName}.${tableName}`;
let ddl = '';
try {
@@ -281,13 +282,13 @@ export const useSidebarObjectActions = ({
const prompt = promptKind === 'explain'
? [
`请解释数据表 ${conn.dbName}.${tableName} 的结构和业务含义。`,
'重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。',
t('sidebar.ai_prompt.explain.intro', { table: tableRef }),
t('sidebar.ai_prompt.explain.detail'),
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n')
: [
`请基于数据表 ${conn.dbName}.${tableName} 生成 3 条常用查询 SQL。`,
'要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。',
t('sidebar.ai_prompt.query.intro', { table: tableRef }),
t('sidebar.ai_prompt.query.detail'),
ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '',
].filter(Boolean).join('\n');
@@ -313,12 +314,12 @@ export const useSidebarObjectActions = ({
const res = await CreateDatabase(buildRpcConnectionConfig(config) as any, values.name);
if (res.success) {
message.success('数据库创建成功');
message.success(t('sidebar.message.database_created'));
setIsCreateDbModalOpen(false);
createDbForm.resetFields();
loadDatabases(targetConnection);
} else {
message.error('创建失败: ' + res.message);
message.error(t('sidebar.message.operation_create_failed', { error: res.message }));
}
} catch (e) {
// Validate failed
@@ -366,7 +367,7 @@ export const useSidebarObjectActions = ({
const dialect = getMetadataDialect(node?.dataRef as SavedConnection);
const schemaName = String(node?.dataRef?.schemaName || '').trim();
if (!isPostgresSchemaDialect(dialect) || !schemaName) {
message.warning('当前节点不支持通过此入口编辑模式');
message.warning(t('sidebar.message.schema_edit_unsupported'));
return;
}
setRenameSchemaTarget(node);
@@ -383,11 +384,11 @@ export const useSidebarObjectActions = ({
const oldSchemaName = String(conn?.schemaName || '').trim();
const newSchemaName = String(values?.newName || '').trim();
if (!conn || !dbName || !oldSchemaName || !newSchemaName) {
message.error('未找到目标模式,无法编辑');
message.error(t('sidebar.message.schema_target_edit_missing'));
return;
}
if (oldSchemaName === newSchemaName) {
message.warning('新旧模式名称相同,无需修改');
message.warning(t('sidebar.message.schema_name_unchanged'));
return;
}
@@ -398,7 +399,7 @@ export const useSidebarObjectActions = ({
newSchemaName,
);
if (res.success) {
message.success('模式重命名成功');
message.success(t('sidebar.message.schema_renamed'));
const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${oldSchemaName || 'default'}`;
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
@@ -407,7 +408,7 @@ export const useSidebarObjectActions = ({
setRenameSchemaTarget(null);
renameSchemaForm.resetFields();
} else {
message.error('编辑失败: ' + res.message);
message.error(t('sidebar.message.rename_failed', { error: res.message }));
}
} catch (e) {
// Validate failed
@@ -419,12 +420,12 @@ export const useSidebarObjectActions = ({
const dbName = String(conn?.dbName || '').trim();
const schemaName = String(conn?.schemaName || '').trim();
if (!conn || !dbName || !schemaName) {
message.error('未找到目标模式,无法删除');
message.error(t('sidebar.message.schema_target_delete_missing'));
return;
}
Modal.confirm({
title: '确认删除模式',
content: `确定删除模式 "${schemaName}" 吗?这将删除该模式及其中所有对象,操作不可恢复。`,
title: t('sidebar.modal.confirm_delete_schema.title'),
content: t('sidebar.modal.confirm_delete_schema.content', { name: schemaName }),
okButtonProps: { danger: true },
onOk: async () => {
const res = await (window as any).go.app.App.DropSchema(
@@ -433,13 +434,13 @@ export const useSidebarObjectActions = ({
schemaName,
);
if (res.success) {
message.success('模式删除成功');
message.success(t('sidebar.message.schema_deleted'));
const schemaKeyPrefix = `${conn.id}-${dbName}-schema-${schemaName || 'default'}`;
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(schemaKeyPrefix)));
await loadTables(getDatabaseNodeRef(conn, dbName));
} else {
message.error('删除失败: ' + res.message);
message.error(t('sidebar.message.delete_failed', { error: res.message }));
}
},
});
@@ -511,23 +512,23 @@ export const useSidebarObjectActions = ({
const oldTableName = String(conn.tableName || '').trim();
const newTableName = String(values.newName || '').trim();
if (!oldTableName || !newTableName) {
message.error('表名不能为空');
message.error(t('sidebar.message.table_name_required'));
return;
}
if (extractObjectName(oldTableName) === newTableName || oldTableName === newTableName) {
message.warning('新旧表名相同,无需修改');
message.warning(t('sidebar.message.table_name_unchanged'));
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameTable(buildRpcConnectionConfig(config) as any, conn.dbName, oldTableName, newTableName);
if (res.success) {
message.success('表重命名成功');
message.success(t('sidebar.message.table_renamed'));
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
setIsRenameTableModalOpen(false);
setRenameTableTarget(null);
renameTableForm.resetFields();
} else {
message.error('重命名失败: ' + res.message);
message.error(t('sidebar.message.rename_failed', { error: res.message }));
}
} catch (e) {
// Validate failed
@@ -539,17 +540,17 @@ export const useSidebarObjectActions = ({
const tableName = String(conn.tableName || '').trim();
if (!tableName) return;
Modal.confirm({
title: '确认删除表',
content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`,
title: t('sidebar.modal.confirm_delete_table.title'),
content: t('sidebar.modal.confirm_delete_table.content', { name: tableName }),
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropTable(buildRpcConnectionConfig(config) as any, conn.dbName, tableName);
if (res.success) {
message.success('表删除成功');
message.success(t('sidebar.message.table_deleted'));
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error('删除失败: ' + res.message);
message.error(t('sidebar.message.delete_failed', { error: res.message }));
}
},
});
@@ -563,10 +564,10 @@ export const useSidebarObjectActions = ({
const { label, progressLabel } = getTableDataDangerActionMeta(action);
const confirmed = await new Promise<boolean>((resolve) => {
Modal.confirm({
title: `确认${label}`,
content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`,
okText: '继续',
cancelText: '取消',
title: t('sidebar.modal.confirm_table_data_action.title', { action: label }),
content: t('sidebar.modal.confirm_table_data_action.content', { action: label, table: tableName }),
okText: t('sidebar.action.continue'),
cancelText: t('common.cancel'),
okButtonProps: { danger: true },
onOk: () => resolve(true),
onCancel: () => resolve(false),
@@ -577,7 +578,10 @@ export const useSidebarObjectActions = ({
const config = buildRuntimeConfig(conn, conn.dbName);
const app = (window as any).go.app.App;
const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables';
const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0);
const hide = message.loading(t('sidebar.message.table_data_action_loading', {
action: progressLabel,
table: tableName,
}), 0);
const startTime = Date.now();
try {
const res = await app[methodName](buildRpcConnectionConfig(config) as any, conn.dbName, [tableName]);
@@ -589,7 +593,7 @@ export const useSidebarObjectActions = ({
: `/* ${label} ${tableName} */`;
if (res.success) {
message.success(`${progressLabel}成功`);
message.success(t('sidebar.message.table_data_action_success', { action: progressLabel }));
addSqlLog({
id: Date.now().toString(),
timestamp: Date.now(),
@@ -614,7 +618,10 @@ export const useSidebarObjectActions = ({
dbName: conn.dbName,
});
if (res.message !== '已取消') {
message.error(`${progressLabel}失败: ${res.message}`);
message.error(t('sidebar.message.table_data_action_failed', {
action: progressLabel,
error: res.message,
}));
}
} catch (e: any) {
const duration = Date.now() - startTime;
@@ -629,7 +636,10 @@ export const useSidebarObjectActions = ({
message: errMsg,
dbName: conn.dbName,
});
message.error(`${progressLabel}失败: ${errMsg}`);
message.error(t('sidebar.message.table_data_action_failed', {
action: progressLabel,
error: errMsg,
}));
}
};
@@ -799,7 +809,7 @@ export const useSidebarObjectActions = ({
: (safeTableParts.length > 0 ? safeTableParts : [safeTable]).map(part => `\`${part.replace(/`/g, '``')}\``).join('.');
addTab({
id: `query-create-starrocks-rollup-${Date.now()}`,
title: '新增 Rollup',
title: t('sidebar.v2_table_menu.new_rollup', { keyword: 'Rollup' }),
type: 'query',
connectionId: id,
dbName,
@@ -812,17 +822,17 @@ export const useSidebarObjectActions = ({
const viewName = String(conn.viewName || '').trim();
if (!viewName) return;
Modal.confirm({
title: '确认删除视图',
content: `确定删除视图 "${viewName}" 吗?该操作不可恢复。`,
title: t('sidebar.modal.confirm_delete_view.title'),
content: t('sidebar.modal.confirm_delete_view.content', { name: viewName }),
okButtonProps: { danger: true },
onOk: async () => {
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await DropView(buildRpcConnectionConfig(config) as any, conn.dbName, viewName);
if (res.success) {
message.success('视图删除成功');
message.success(t('sidebar.message.view_deleted'));
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
} else {
message.error('删除失败: ' + res.message);
message.error(t('sidebar.message.delete_failed', { error: res.message }));
}
},
});
@@ -836,23 +846,23 @@ export const useSidebarObjectActions = ({
const oldViewName = String(conn.viewName || '').trim();
const newViewName = String(values.newName || '').trim();
if (!oldViewName || !newViewName) {
message.error('视图名称不能为空');
message.error(t('sidebar.message.view_name_required'));
return;
}
if (extractObjectName(oldViewName) === newViewName || oldViewName === newViewName) {
message.warning('新旧视图名相同,无需修改');
message.warning(t('sidebar.message.view_name_unchanged'));
return;
}
const config = buildRuntimeConfig(conn, conn.dbName);
const res = await RenameView(buildRpcConnectionConfig(config) as any, conn.dbName, oldViewName, newViewName);
if (res.success) {
message.success('视图重命名成功');
message.success(t('sidebar.message.view_renamed'));
await loadTables(getDatabaseNodeRef(conn, conn.dbName));
setIsRenameViewModalOpen(false);
setRenameViewTarget(null);
renameViewForm.resetFields();
} else {
message.error('重命名失败: ' + res.message);
message.error(t('sidebar.message.rename_failed', { error: res.message }));
}
} catch (e) {
// Validate failed
@@ -905,9 +915,9 @@ export const useSidebarObjectActions = ({
setRenameSavedQueryTarget(null);
renameSavedQueryForm.resetFields();
} catch (e) {
if (e instanceof Error) {
message.error('重命名查询失败: ' + e.message);
}
message.error(t('sidebar.message.saved_query_rename_failed', {
error: e instanceof Error ? e.message : String(e),
}));
}
};
@@ -931,7 +941,9 @@ export const useSidebarObjectActions = ({
bindingStatus: 'active',
});
}
message.success(`查询已绑定到 ${target.name || target.id}`);
message.success(t('sidebar.message.saved_query_rebind_success', {
name: target.name || target.id,
}));
tabs
.filter(tab => tab.type === 'query' && (tab.savedQueryId === query.id || tab.id === query.id))
.forEach(tab => updateQueryTabDraft(tab.id, {
@@ -940,7 +952,9 @@ export const useSidebarObjectActions = ({
dbName: persisted.dbName,
}));
} catch (error) {
message.error('绑定查询失败: ' + (error instanceof Error ? error.message : String(error)));
message.error(t('sidebar.message.saved_query_rebind_failed', {
error: error instanceof Error ? error.message : String(error),
}));
}
}, [saveQuery, tabs, updateQueryTabDraft]);
@@ -1153,16 +1167,22 @@ export const useSidebarObjectActions = ({
const openMessagePublishModal = (node: any) => {
const target = resolveMessagePublishTarget(node);
if (!target) {
message.warning('当前对象不支持测试发送消息');
message.warning(t('sidebar.message.message_publish_unsupported'));
return;
}
setMessagePublishTarget(target);
};
const handleMessagePublishSuccess = (result: { destination: string; affectedRows: number }) => {
const destination = String(result.destination || '').trim();
const suffix = result.affectedRows > 0 ? `(已提交 ${result.affectedRows} 条)` : '';
message.success(`测试消息已发送到 ${destination || '目标'}${suffix}`);
const destination = String(result.destination || '').trim() || t('sidebar.message.message_publish_target_fallback');
if (result.affectedRows > 0) {
message.success(t('sidebar.message.message_publish_success_with_count', {
destination,
count: result.affectedRows,
}));
} else {
message.success(t('sidebar.message.message_publish_success', { destination }));
}
setMessagePublishTarget(null);
};

View File

@@ -363,7 +363,7 @@ export const useSidebarV2ActionHandlers = ({
if (!conn?.config) return;
const res = await DBReleaseConnection(buildRpcConnectionConfig(conn.config, { id: conn.id }) as any);
if (res && res.success === false) {
throw new Error(res.message || '释放连接失败');
throw new Error(String(res.message || '').trim());
}
};
@@ -392,7 +392,7 @@ export const useSidebarV2ActionHandlers = ({
try {
await releaseConnectionResources(conn);
} catch (error: any) {
message.warning(error?.message || '连接已从侧边栏断开,但后端连接释放失败');
message.warning(String(error?.message || '').trim() || t('sidebar.message.connection_release_failed_from_sidebar'));
}
message.success(t('connection.sidebar.disconnect.success'));
};

View File

@@ -486,20 +486,32 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
};
}, [connections, connectionId]);
const resolveExportTitle = useCallback((defaultName: string) => {
const normalizedDefaultName = String(defaultName || '').trim();
if (normalizedDefaultName === 'query_result') {
return translateDataGrid('file.backend.dialog.export_query_result');
}
if (normalizedDefaultName && normalizedDefaultName !== 'export') {
return translateDataGrid('file.backend.dialog.export_table', { table: normalizedDefaultName });
}
return translateDataGrid('file.backend.dialog.export_data');
}, [translateDataGrid]);
const exportByQuery = useCallback(async (sql: string, defaultName: string, options: DataExportFileOptions, totalRows?: number) => {
const config = buildConnConfig();
if (!config) return;
const normalizedDefaultName = String(defaultName || '').trim();
const totalRowsKnown = Number.isFinite(totalRows) && Number(totalRows) >= 0;
await runExportWithProgress({
title: `导出 ${defaultName || '查询结果'}`,
targetName: defaultName || 'export',
title: resolveExportTitle(normalizedDefaultName),
targetName: normalizedDefaultName || 'export',
format: options.format,
totalRows: totalRowsKnown ? Number(totalRows) : undefined,
run: (jobId: string) => ExportQueryWithOptions(
buildRpcConnectionConfig(config) as any,
dbName || '',
sql,
defaultName || 'export',
normalizedDefaultName || 'export',
{
...options,
jobId,
@@ -508,7 +520,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
} as any,
),
});
}, [buildConnConfig, dbName, runExportWithProgress]);
}, [buildConnConfig, dbName, resolveExportTitle, runExportWithProgress]);
const buildPkWhereSql = useCallback((rows: any[], dbType: string) => {
if (!tableName || pkColumns.length === 0) return '';
@@ -788,8 +800,8 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
const handleOpenExportDialog = useCallback(async () => {
const selectedCount = selectedRowKeys.length;
const allRowsLabel = (resultExportAllSql || resultSql)
? '全部结果(重新查询)'
: `全部结果(当前缓存 ${mergedDisplayData.length} 条)`;
? translateDataGrid('data_grid.export.scope.all_results_requery')
: translateDataGrid('data_grid.export.scope.all_results_cached', { count: mergedDisplayData.length });
const commonInitialValues: Partial<DataExportDialogValues> = {
format: DEFAULT_DATA_EXPORT_FORMAT,
xlsxMaxRowsPerSheet: DEFAULT_XLSX_ROWS_PER_SHEET,
@@ -799,25 +811,29 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
const scopeOptions: DataExportScopeOption[] = [
{
value: 'selected',
label: selectedCount > 0 ? `选中行 (${selectedCount} 条)` : '选中行',
description: '仅导出当前结果集中已勾选的行。',
label: selectedCount > 0
? translateDataGrid('data_grid.export.scope.selected_rows_count', { count: selectedCount })
: translateDataGrid('data_grid.export.scope.selected_rows'),
description: translateDataGrid('data_grid.export.scope.selected_rows_description'),
disabled: selectedCount <= 0,
},
{
value: 'page',
label: `当前页 (${queryResultCurrentPageRows.length} 条)`,
description: '直接按当前结果页缓存导出。',
label: translateDataGrid('data_grid.export.scope.current_page', {
count: queryResultCurrentPageRows.length,
}),
description: translateDataGrid('data_grid.export.scope.current_page_description'),
},
{
value: 'all',
label: allRowsLabel,
description: (resultExportAllSql || resultSql)
? '后台会重新执行 SQL避免只导出当前页或当前缓存。'
: '当前查询缺少可重放 SQL 时,将导出当前缓存的全部结果。',
? translateDataGrid('data_grid.export.scope.all_results_requery_description')
: translateDataGrid('data_grid.export.scope.all_results_cached_description'),
},
];
const values = await showDataExportDialog(modal, {
title: '导出查询结果',
title: translateDataGrid('file.backend.dialog.export_query_result'),
scopeOptions,
initialValues: {
...commonInitialValues,
@@ -842,29 +858,31 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
connectionId,
dbName,
tableName: tableName || 'export',
title: `导出 ${tableName || '数据'}`,
title: resolveExportTitle(tableName || 'export'),
objectType,
scopeOptions: [
{
value: 'page',
label: `当前页 (${displayData.length} 条)`,
label: translateDataGrid('data_grid.export.scope.current_page', {
count: displayData.length,
}),
description: currentPageSql
? '后台按当前分页条件重新查询后导出当前页。'
: '当前页依赖前端临时状态,建议直接使用快捷导出。',
? translateDataGrid('data_grid.export.scope.current_page_requery_description')
: translateDataGrid('data_grid.export.scope.current_page_unavailable_description'),
disabled: !currentPageSql,
},
...(hasFilteredExportSql ? [{
value: 'filteredAll' as const,
label: '筛选结果(全部)',
label: translateDataGrid('data_grid.export.scope.filtered_results_all'),
description: filteredAllSql
? '按当前筛选条件重新查询数据库并导出全部筛选结果。'
: '当前数据源或当前状态暂不支持在工作台重放筛选导出。',
? translateDataGrid('data_grid.export.scope.filtered_results_all_requery_description')
: translateDataGrid('data_grid.export.scope.filtered_results_all_unavailable_description'),
disabled: !filteredAllSql,
}] : []),
{
value: 'all',
label: '全表数据',
description: '后台重新查询整张表并导出全部数据。',
label: translateDataGrid('data_export.workbench.scope.all.label'),
description: translateDataGrid('data_export.workbench.scope.all.description'),
},
],
initialScope: hasFilteredExportSql && filteredAllSql ? 'filteredAll' : 'all',
@@ -902,6 +920,7 @@ const handleV2ColumnHeaderContextMenuAction = useCallback((action: V2ColumnHeade
supportsSqlQueryExport,
tableName,
hasChanges,
translateDataGrid,
]);
return {

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { setCurrentLanguage } from '../i18n';
import { useExportProgressRunner } from './useExportProgressRunner';
const runtimeApi = vi.hoisted(() => {
@@ -58,6 +59,7 @@ describe('useExportProgressRunner', () => {
runner = null;
renderer = null;
now = 1_000;
setCurrentLanguage('zh-CN');
runtimeApi.reset();
runtimeApi.EventsOn.mockClear();
messageApi.warning.mockReset();
@@ -70,6 +72,7 @@ describe('useExportProgressRunner', () => {
act(() => {
renderer?.unmount();
});
setCurrentLanguage('en-US');
vi.restoreAllMocks();
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { message } from 'antd';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { t } from '../i18n';
import type { ExportProgressStatus } from '../utils/exportProgress';
export type ExportProgressEvent = {
@@ -83,6 +84,7 @@ const hasUsableTotalRows = (known: boolean, total: unknown): boolean => {
};
const buildExportJobId = (): string => `export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const EXPORT_CANCELED_MESSAGE = '\u5df2\u53d6\u6d88';
const isActiveExportStatus = (status: ExportProgressStatus): boolean =>
status === 'start' || status === 'running' || status === 'finalizing' || status === 'done' || status === 'error';
@@ -145,7 +147,7 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions
): Promise<T | null> => {
if (state.open && (state.status === 'start' || state.status === 'running' || state.status === 'finalizing')) {
if (showToast) {
void message.warning('当前已有导出任务正在执行,请等待完成后再发起新的导出');
void message.warning(t('data_export.message.already_running'));
}
return null;
}
@@ -166,7 +168,7 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions
startedAt: 0,
finishedAt: 0,
status: 'start',
stage: '等待选择导出文件',
stage: t('data_export.progress.stage.waiting_file_selection'),
current: 0,
total: totalRowsKnown ? requestedTotal : 0,
totalRowsKnown,
@@ -186,15 +188,15 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions
open: true,
status: 'done',
finishedAt: prev.finishedAt || Date.now(),
stage: prev.stage || '导出完成',
stage: prev.stage || t('data_export.progress.title.done'),
current: prev.totalRowsKnown ? Math.max(prev.current, prev.total) : prev.current,
message: '',
};
});
if (showToast) {
void message.success('导出成功');
void message.success(t('data_export.message.export_success'));
}
} else if (result.message !== '已取消') {
} else if (result.message !== EXPORT_CANCELED_MESSAGE) {
setState((prev) => {
if (prev.jobId !== jobId) {
return prev;
@@ -204,12 +206,12 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions
open: true,
status: 'error',
finishedAt: prev.finishedAt || Date.now(),
stage: prev.stage || '导出失败',
stage: prev.stage || t('data_export.progress.title.error'),
message: result.message,
};
});
if (showToast) {
void message.error(`导出失败: ${result.message}`);
void message.error(t('data_export.message.export_failed', { error: result.message }));
}
} else {
reset();
@@ -226,12 +228,12 @@ export function useExportProgressRunner(options?: UseExportProgressRunnerOptions
open: true,
status: 'error',
finishedAt: prev.finishedAt || Date.now(),
stage: prev.stage || '导出失败',
stage: prev.stage || t('data_export.progress.title.error'),
message: errorMessage,
};
});
if (showToast) {
void message.error(`导出失败: ${errorMessage}`);
void message.error(t('data_export.message.export_failed', { error: errorMessage }));
}
throw error;
}