mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-04 12:57:39 +08:00
🐛 fix(i18n): 合并 dev 后补齐只读保护多语言
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {");
|
||||
|
||||
@@ -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,
|
||||
|
||||
24
frontend/src/components/ExportTitles.i18n.test.ts
Normal file
24
frontend/src/components/ExportTitles.i18n.test.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)) }
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'));
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user