From 9364c48ef01a04d0e61862b631219ebd53dece10 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:17:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(i18n):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E5=A4=9A=E6=A8=A1=E5=9D=97=E5=A4=9A=E8=AF=AD=E8=A8=80=E9=80=82?= =?UTF-8?q?=E9=85=8D=E4=B8=8E=E5=8F=91=E7=89=88=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展前后端多语言文案与共享词典。增加多模块 i18n 回归测试与 guard。收口外部 SQL 菜单和弹窗多语言文案。 --- frontend/src/App.tab-display.i18n.test.ts | 75 +++ frontend/src/App.tool-center.test.ts | 13 +- frontend/src/App.tools-entry.i18n.test.ts | 22 + frontend/src/App.tsx | 85 +-- frontend/src/App.ui-version.test.ts | 39 +- frontend/src/components/AIChatPanel.tsx | 4 +- .../AISettingsModal.edit-password.test.tsx | 76 ++- frontend/src/components/AISettingsModal.tsx | 68 +-- .../ConnectionModal.edit-password.test.tsx | 190 +++++- frontend/src/components/ConnectionModal.tsx | 119 ++-- .../ConnectionModalMongoSections.tsx | 541 ++++++++++-------- .../ConnectionModalRedisSections.tsx | 332 +++++------ .../DataGrid.auto-commit-delay.i18n.test.ts | 19 + .../DataGrid.auto-commit.i18n.test.ts | 14 + .../DataGrid.cell-undo-menu.i18n.test.ts | 17 + .../DataGrid.cell-undo.i18n.test.ts | 24 + ...aGrid.embedded-designer-title.i18n.test.ts | 23 + .../src/components/DataGrid.layout.test.tsx | 24 +- .../DataGrid.secondary-actions.i18n.test.ts | 12 + frontend/src/components/DataGrid.tsx | 37 +- .../DataGridLegacyCellContextMenu.tsx | 2 +- .../components/DataGridSecondaryActions.tsx | 2 +- .../DataGridToolbarFrame.i18n.test.ts | 26 + .../src/components/DataGridToolbarFrame.tsx | 8 +- .../DataSyncModal.entry-mode.test.ts | 48 +- .../src/components/DataSyncModal.i18n.test.ts | 28 + frontend/src/components/DataSyncModal.tsx | 49 +- .../src/components/LinuxCJKFontBanner.tsx | 89 +-- .../MessagePublishModal.i18n.test.ts | 166 ++++++ .../src/components/MessagePublishModal.tsx | 79 +-- .../QueryEditor.external-sql-save.test.tsx | 10 +- .../src/components/QueryEditor.i18n.test.ts | 43 ++ frontend/src/components/QueryEditor.tsx | 31 +- .../QueryEditorResultsPanel.i18n.test.ts | 62 ++ .../components/QueryEditorResultsPanel.tsx | 48 +- .../QueryEditorToolbar.i18n.test.ts | 72 +++ .../src/components/QueryEditorToolbar.tsx | 79 ++- .../QueryEditorTransaction.i18n.test.ts | 53 ++ .../QueryEditorTransactionSettings.tsx | 58 +- .../QueryEditorTransactionToolbar.tsx | 16 +- .../RedisCommandEditor.i18n.test.ts | 34 ++ .../src/components/RedisCommandEditor.tsx | 23 +- .../src/components/RedisMonitor.i18n.test.ts | 46 ++ frontend/src/components/RedisMonitor.tsx | 65 ++- .../src/components/RedisViewer.i18n.test.ts | 29 + .../src/components/RedisViewerKeyToolbar.tsx | 42 +- .../SecurityUpdateBanner.i18n.test.tsx | 121 ++++ .../src/components/SecurityUpdateBanner.tsx | 22 +- .../SecurityUpdateIntroModal.i18n.test.tsx | 109 ++++ .../components/SecurityUpdateIntroModal.tsx | 16 +- .../SecurityUpdateProgressModal.i18n.test.tsx | 94 +++ .../SecurityUpdateProgressModal.tsx | 5 +- .../SecurityUpdateSettingsModal.i18n.test.tsx | 177 ++++++ .../SecurityUpdateSettingsModal.tsx | 52 +- .../Sidebar.message-publish.test.tsx | 2 +- frontend/src/components/Sidebar.tsx | 495 ++++++++-------- .../SidebarBatchClearFeedback.i18n.test.ts | 74 +++ ...arBatchDatabaseExportFeedback.i18n.test.ts | 64 +++ ...ebarBatchObjectExportFeedback.i18n.test.ts | 70 +++ ...idebarBatchObjectLoadFeedback.i18n.test.ts | 43 ++ .../SidebarCopyObjectName.i18n.test.ts | 48 ++ .../SidebarCopyStructure.i18n.test.ts | 22 + .../SidebarCreateRoutine.i18n.test.ts | 23 + ...arCreateRoutineDuckDBTemplate.i18n.test.ts | 23 + .../SidebarDangerOperationsMenu.i18n.test.ts | 11 + ...SidebarDatabaseExportFeedback.i18n.test.ts | 55 ++ .../SidebarDeleteRoutineMenu.i18n.test.ts | 27 + .../SidebarDropRoutineConfirm.i18n.test.ts | 36 ++ .../SidebarEditDefinitionMenu.i18n.test.ts | 11 + .../SidebarEditEventTab.i18n.test.ts | 22 + ...SidebarEditRoutineSqlTemplate.i18n.test.ts | 23 + .../SidebarEditRoutineTab.i18n.test.ts | 23 + .../SidebarEditViewSqlTemplate.i18n.test.ts | 24 + .../components/SidebarEventTab.i18n.test.ts | 22 + ...idebarExternalSqlCrudFeedback.i18n.test.ts | 58 ++ ...ebarExternalSqlDeleteFeedback.i18n.test.ts | 53 ++ .../SidebarExternalSqlMenuLabels.i18n.test.ts | 62 ++ .../SidebarExternalSqlModal.i18n.test.ts | 54 ++ ...idebarExternalSqlOpenFeedback.i18n.test.ts | 26 + .../SidebarExternalSqlRefresh.i18n.test.ts | 22 + .../components/SidebarFilterSync.i18n.test.ts | 46 ++ .../SidebarLocateMessages.i18n.test.ts | 53 ++ ...barMaterializedViewCreateMenu.i18n.test.ts | 19 + ...barMaterializedViewMenuLabels.i18n.test.ts | 22 + .../SidebarNewTableUnsupported.i18n.test.ts | 21 + .../SidebarObjectGroups.i18n.test.ts | 50 ++ .../SidebarRedisDbMenu.i18n.test.ts | 24 + .../SidebarRoutineDefinitionTab.i18n.test.ts | 23 + .../SidebarSavedQueriesTree.i18n.test.ts | 42 ++ .../SidebarSchemaExportFeedback.i18n.test.ts | 59 ++ .../SidebarSphinxCapability.i18n.test.ts | 41 ++ .../SidebarTableExportFeedback.i18n.test.ts | 50 ++ .../SidebarTableFolders.i18n.test.ts | 33 ++ .../components/SidebarTableTabs.i18n.test.ts | 37 ++ .../SidebarTablesExportFeedback.i18n.test.ts | 59 ++ .../components/SidebarTriggerTab.i18n.test.ts | 22 + .../SidebarV2GroupFallback.i18n.test.ts | 36 ++ .../SidebarViewCreateEdit.i18n.test.ts | 28 + .../SidebarViewDefinitionMenu.i18n.test.ts | 21 + .../SidebarViewDefinitionTab.i18n.test.ts | 20 + .../SidebarViewMenuLabels.i18n.test.ts | 28 + .../src/components/V2TableContextMenu.tsx | 2 +- .../ai/AIBuiltinToolsCatalog.test.tsx | 54 +- .../components/ai/AIBuiltinToolsCatalog.tsx | 36 +- frontend/src/components/dataSyncEntryMode.ts | 69 ++- frontend/src/components/sidebarV2Utils.ts | 5 +- frontend/src/main.browserMock.test.ts | 202 +++++++ frontend/src/main.tsx | 35 +- frontend/src/utils/aiToolRegistry.test.ts | 26 + frontend/src/utils/aiToolRegistry.ts | 16 +- frontend/src/utils/messagePublish.test.ts | 219 ++++++- frontend/src/utils/messagePublish.ts | 98 ++-- .../src/utils/savedQueryPersistence.test.ts | 31 + frontend/src/utils/savedQueryPersistence.ts | 7 +- .../src/utils/secureConfigBootstrap.test.ts | 44 ++ frontend/src/utils/secureConfigBootstrap.ts | 30 +- .../utils/securityUpdatePresentation.test.ts | 36 +- .../src/utils/securityUpdatePresentation.ts | 103 ++-- .../utils/securityUpdateRepairFlow.test.ts | 35 +- .../src/utils/securityUpdateRepairFlow.ts | 9 +- frontend/src/utils/tabDisplay.ts | 32 +- internal/app/methods_saved_connections.go | 6 +- .../app/methods_saved_connections_test.go | 23 +- internal/app/methods_saved_queries.go | 22 +- internal/app/saved_connections.go | 16 +- internal/app/saved_queries.go | 6 +- internal/app/saved_queries_test.go | 31 + internal/app/security_update_engine.go | 92 +-- internal/app/security_update_engine_test.go | 234 ++++++++ shared/i18n/de-DE.json | 423 +++++++++++++- shared/i18n/en-US.json | 423 +++++++++++++- shared/i18n/ja-JP.json | 423 +++++++++++++- shared/i18n/ru-RU.json | 423 +++++++++++++- shared/i18n/zh-CN.json | 425 +++++++++++++- shared/i18n/zh-TW.json | 425 +++++++++++++- 135 files changed, 8401 insertions(+), 1325 deletions(-) create mode 100644 frontend/src/App.tab-display.i18n.test.ts create mode 100644 frontend/src/App.tools-entry.i18n.test.ts create mode 100644 frontend/src/components/DataGrid.auto-commit-delay.i18n.test.ts create mode 100644 frontend/src/components/DataGrid.auto-commit.i18n.test.ts create mode 100644 frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts create mode 100644 frontend/src/components/DataGrid.cell-undo.i18n.test.ts create mode 100644 frontend/src/components/DataGrid.embedded-designer-title.i18n.test.ts create mode 100644 frontend/src/components/DataGrid.secondary-actions.i18n.test.ts create mode 100644 frontend/src/components/DataGridToolbarFrame.i18n.test.ts create mode 100644 frontend/src/components/MessagePublishModal.i18n.test.ts create mode 100644 frontend/src/components/QueryEditorResultsPanel.i18n.test.ts create mode 100644 frontend/src/components/QueryEditorToolbar.i18n.test.ts create mode 100644 frontend/src/components/QueryEditorTransaction.i18n.test.ts create mode 100644 frontend/src/components/RedisCommandEditor.i18n.test.ts create mode 100644 frontend/src/components/RedisMonitor.i18n.test.ts create mode 100644 frontend/src/components/SecurityUpdateBanner.i18n.test.tsx create mode 100644 frontend/src/components/SecurityUpdateIntroModal.i18n.test.tsx create mode 100644 frontend/src/components/SecurityUpdateProgressModal.i18n.test.tsx create mode 100644 frontend/src/components/SecurityUpdateSettingsModal.i18n.test.tsx create mode 100644 frontend/src/components/SidebarBatchClearFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarBatchDatabaseExportFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarBatchObjectExportFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarBatchObjectLoadFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarCopyObjectName.i18n.test.ts create mode 100644 frontend/src/components/SidebarCopyStructure.i18n.test.ts create mode 100644 frontend/src/components/SidebarCreateRoutine.i18n.test.ts create mode 100644 frontend/src/components/SidebarCreateRoutineDuckDBTemplate.i18n.test.ts create mode 100644 frontend/src/components/SidebarDangerOperationsMenu.i18n.test.ts create mode 100644 frontend/src/components/SidebarDatabaseExportFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarDeleteRoutineMenu.i18n.test.ts create mode 100644 frontend/src/components/SidebarDropRoutineConfirm.i18n.test.ts create mode 100644 frontend/src/components/SidebarEditDefinitionMenu.i18n.test.ts create mode 100644 frontend/src/components/SidebarEditEventTab.i18n.test.ts create mode 100644 frontend/src/components/SidebarEditRoutineSqlTemplate.i18n.test.ts create mode 100644 frontend/src/components/SidebarEditRoutineTab.i18n.test.ts create mode 100644 frontend/src/components/SidebarEditViewSqlTemplate.i18n.test.ts create mode 100644 frontend/src/components/SidebarEventTab.i18n.test.ts create mode 100644 frontend/src/components/SidebarExternalSqlCrudFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarExternalSqlDeleteFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarExternalSqlMenuLabels.i18n.test.ts create mode 100644 frontend/src/components/SidebarExternalSqlModal.i18n.test.ts create mode 100644 frontend/src/components/SidebarExternalSqlOpenFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarExternalSqlRefresh.i18n.test.ts create mode 100644 frontend/src/components/SidebarFilterSync.i18n.test.ts create mode 100644 frontend/src/components/SidebarLocateMessages.i18n.test.ts create mode 100644 frontend/src/components/SidebarMaterializedViewCreateMenu.i18n.test.ts create mode 100644 frontend/src/components/SidebarMaterializedViewMenuLabels.i18n.test.ts create mode 100644 frontend/src/components/SidebarNewTableUnsupported.i18n.test.ts create mode 100644 frontend/src/components/SidebarObjectGroups.i18n.test.ts create mode 100644 frontend/src/components/SidebarRedisDbMenu.i18n.test.ts create mode 100644 frontend/src/components/SidebarRoutineDefinitionTab.i18n.test.ts create mode 100644 frontend/src/components/SidebarSavedQueriesTree.i18n.test.ts create mode 100644 frontend/src/components/SidebarSchemaExportFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarSphinxCapability.i18n.test.ts create mode 100644 frontend/src/components/SidebarTableExportFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarTableFolders.i18n.test.ts create mode 100644 frontend/src/components/SidebarTableTabs.i18n.test.ts create mode 100644 frontend/src/components/SidebarTablesExportFeedback.i18n.test.ts create mode 100644 frontend/src/components/SidebarTriggerTab.i18n.test.ts create mode 100644 frontend/src/components/SidebarV2GroupFallback.i18n.test.ts create mode 100644 frontend/src/components/SidebarViewCreateEdit.i18n.test.ts create mode 100644 frontend/src/components/SidebarViewDefinitionMenu.i18n.test.ts create mode 100644 frontend/src/components/SidebarViewDefinitionTab.i18n.test.ts create mode 100644 frontend/src/components/SidebarViewMenuLabels.i18n.test.ts diff --git a/frontend/src/App.tab-display.i18n.test.ts b/frontend/src/App.tab-display.i18n.test.ts new file mode 100644 index 0000000..46e7116 --- /dev/null +++ b/frontend/src/App.tab-display.i18n.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const appSource = readFileSync( + fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)), + 'utf8', +); + +const tabDisplaySource = readFileSync( + fileURLToPath(new globalThis.URL('./utils/tabDisplay.ts', import.meta.url)), + 'utf8', +); + +describe('App tab display i18n guards', () => { + it('localizes the tab display settings copy and preview labels', () => { + [ + 'app.theme.tab_display.title', + 'app.theme.tab_display.description', + 'app.theme.tab_display.layout.single', + 'app.theme.tab_display.layout.double', + 'app.theme.tab_display.badge.current', + 'app.theme.tab_display.row.primary', + 'app.theme.tab_display.row.secondary', + 'app.theme.tab_display.action.move_up', + 'app.theme.tab_display.action.move_down', + 'app.theme.tab_display.preview.prefix', + 'app.theme.tab_display.preview.default_label', + 'app.theme.tab_display.preview.secondary', + 'app.theme.tab_display.preview.focused', + ].forEach((key) => { + expect(appSource).toContain(`t('${key}'`); + }); + + [ + 'Tab 标签展示', + '自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序', + "'单行'", + "'双行'", + '当前预览:', + '默认标签', + ',副行', + ';当前选中', + '上移', + '下移', + ].forEach((legacyText) => { + expect(appSource).not.toContain(legacyText); + }); + }); + + it('keeps tab display element metadata as i18n keys', () => { + [ + 'connection', + 'kind', + 'object', + 'database', + 'schema', + 'host', + ].forEach((elementKey) => { + expect(tabDisplaySource).toContain(`labelKey: 'app.theme.tab_display.element.${elementKey}.label'`); + expect(tabDisplaySource).toContain(`descriptionKey: 'app.theme.tab_display.element.${elementKey}.description'`); + }); + + [ + '连接名', + '连接简称或环境名', + '对象类型', + '对象名', + '当前 DB / catalog 名称', + '连接目标地址摘要', + ].forEach((legacyText) => { + expect(tabDisplaySource).not.toContain(legacyText); + }); + }); +}); diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 98dcd79..78137f1 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -280,7 +280,18 @@ describe('global appearance tokens', () => { expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';"); expect(appSource).toContain(' { + it('localizes compare tool entry titles and descriptions', () => { + expect(appSource).toContain("t('app.tools.entry.schema_compare.title')"); + expect(appSource).toContain("t('app.tools.entry.schema_compare.description')"); + expect(appSource).toContain("t('app.tools.entry.data_compare.title')"); + expect(appSource).toContain("t('app.tools.entry.data_compare.description')"); + + expect(appSource).not.toContain("title: '表结构比对'"); + expect(appSource).not.toContain("description: '对比源表与目标表结构差异,只预览不执行。'"); + expect(appSource).not.toContain("title: '数据比对'"); + expect(appSource).not.toContain("description: '按主键分析新增、更新、删除和相同行。'"); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3daad4f..ae04004 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -271,6 +271,14 @@ function App() { ]), [tabDisplaySettings], ); + const getTabDisplayElementLabel = useCallback( + (key: TabDisplayElementKey) => t(TAB_DISPLAY_ELEMENT_META[key].labelKey), + [t], + ); + const getTabDisplayElementDescription = useCallback( + (key: TabDisplayElementKey) => t(TAB_DISPLAY_ELEMENT_META[key].descriptionKey), + [t], + ); const setTabDisplaySettings = useCallback((settings: Partial) => { setAppearance({ tabDisplay: applyTabDisplaySettingsPatch(tabDisplaySettings, settings), @@ -397,8 +405,8 @@ function App() { const windowDiagLastAtRef = React.useRef(0); const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig); const securityUpdateStatusMeta = useMemo( - () => getSecurityUpdateStatusMeta(securityUpdateStatus), - [securityUpdateStatus], + () => getSecurityUpdateStatusMeta(securityUpdateStatus, t), + [securityUpdateStatus, t], ); const securityUpdateEntryVisibility = useMemo( () => resolveSecurityUpdateEntryVisibility(securityUpdateStatus), @@ -564,6 +572,7 @@ function App() { backend: (window as any).go?.app?.App, replaceConnections, replaceGlobalProxy, + t, }); if (cancelled) { return; @@ -584,7 +593,7 @@ function App() { return () => { cancelled = true; }; - }, [applySecurityUpdateStatus, isStoreHydrated, replaceConnections, replaceGlobalProxy]); + }, [applySecurityUpdateStatus, isStoreHydrated, replaceConnections, replaceGlobalProxy, t]); useEffect(() => { if (!isStoreHydrated || !hasLoadedSecureConfig) { @@ -1348,6 +1357,7 @@ function App() { backend: backendApp, replaceConnections, replaceGlobalProxy, + t, }); if (result.error) { throw result.error; @@ -1380,6 +1390,7 @@ function App() { backend: backendApp, replaceConnections, replaceGlobalProxy, + t, }, nextStatus); } @@ -1445,6 +1456,7 @@ function App() { const nextStatus = mergeSecurityUpdateStatusWithLegacySource( await backendApp.DismissSecurityUpdateReminder(), securityUpdateRawPayload, + { t }, ); applySecurityUpdateStatus(nextStatus); return; @@ -1468,7 +1480,7 @@ function App() { t, ]); const handleSecurityUpdateIssueAction = useCallback((issue: SecurityUpdateIssue) => { - const repairEntry = resolveSecurityUpdateRepairEntry(issue, connections, securityUpdateStatus); + const repairEntry = resolveSecurityUpdateRepairEntry(issue, connections, securityUpdateStatus, t); if (repairEntry.type === 'warning') { void message.warning(repairEntry.message); return; @@ -1499,7 +1511,7 @@ function App() { } setSecurityUpdateRepairSource(null); openSecurityUpdateSettings(repairEntry.focusTarget); - }, [connections, openSecurityUpdateSettings, runSecurityUpdateRound, securityUpdateStatus]); + }, [connections, openSecurityUpdateSettings, runSecurityUpdateRound, securityUpdateStatus, t]); const updateCheckInFlightRef = React.useRef(false); const updateDownloadInFlightRef = React.useRef(false); const updateUserDismissedRef = React.useRef(false); @@ -2677,6 +2689,7 @@ function App() { backend: backendApp, replaceConnections, replaceGlobalProxy, + t, }, normalizeSecurityUpdateStatus(rawStatus)); applySecurityUpdateStatus(nextStatus, { @@ -2706,6 +2719,7 @@ function App() { : securityUpdateStatus; const nextStatus = mergeSecurityUpdateStatusWithLegacySource(rawStatus, nextRawPayload, { previousStatus: securityUpdateStatus, + t, }); const nextHasLegacySensitiveItems = hasLegacyMigratableSensitiveItems(nextRawPayload); @@ -2723,6 +2737,7 @@ function App() { securityUpdateRepairSource, securityUpdateStatus, securityUpdateStatus.migrationId, + t, ]); const handleCloseModal = () => { @@ -3828,8 +3843,8 @@ function App() { { key: 'schema-compare', icon: , - title: '表结构比对', - description: '对比源表与目标表结构差异,只预览不执行。', + title: t('app.tools.entry.schema_compare.title'), + description: t('app.tools.entry.schema_compare.description'), onClick: () => { setIsToolsModalOpen(false); setSyncModalEntryMode('schemaCompare'); @@ -3839,8 +3854,8 @@ function App() { { key: 'data-compare', icon: , - title: '数据比对', - description: '按主键分析新增、更新、删除和相同行。', + title: t('app.tools.entry.data_compare.title'), + description: t('app.tools.entry.data_compare.description'), onClick: () => { setIsToolsModalOpen(false); setSyncModalEntryMode('dataCompare'); @@ -4409,18 +4424,18 @@ function App() { )} {appearance.uiVersion === 'v2' && (
-
新版左侧搜索模式
+
{t('app.theme.ui_version.sidebar_search.title')}
setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })} />
- 新版命令搜索适合跳转连接、表和动作,可在面板中开启同步开关持续过滤左侧树;旧版侧栏筛选会直接显示输入框并持久保留筛选内容。 + {t('app.theme.ui_version.sidebar_search.hint')}
)} @@ -4546,9 +4561,9 @@ function App() { lineHeight: 1.7, }} > - Ubuntu/Linux 未检测到中文 CJK 字体,界面可能显示方框。请安装: + {t('app.theme.font_family.linux_cjk_install_prefix')} {linuxCJKFontInstallHint} - ,然后重启 GoNavi。 + {t('app.theme.font_family.linux_cjk_install_suffix')} )} @@ -4588,16 +4603,16 @@ function App() {
-
Tab 标签展示
+
{t('app.theme.tab_display.title')}
- 自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序;双行模式可把上下文放到副行。 + {t('app.theme.tab_display.description')}
setTabDisplayLayout(value as TabDisplayLayout)} @@ -4605,7 +4620,6 @@ function App() {
{tabDisplayElementOrder.map((key) => { - const meta = TAB_DISPLAY_ELEMENT_META[key]; const checked = visibleTabDisplayElementKeys.has(key); const row = tabDisplaySettings.secondaryElements.includes(key) ? 'secondary' : 'primary'; const currentRowElements = row === 'secondary' @@ -4678,7 +4692,7 @@ function App() { />
- {meta.label} + {getTabDisplayElementLabel(key)} {isFocused ? ( - 当前 + {t('app.theme.tab_display.badge.current')} ) : null} {checked && tabDisplaySettings.layout === 'double' ? ( @@ -4704,11 +4718,13 @@ function App() { ? (darkMode ? '#7dd3fc' : '#0369a1') : (darkMode ? '#86efac' : '#15803d'), }}> - {row === 'secondary' ? '副行' : '主行'} + {row === 'secondary' + ? t('app.theme.tab_display.row.secondary') + : t('app.theme.tab_display.row.primary')} ) : null}
-
{meta.description}
+
{getTabDisplayElementDescription(key)}
@@ -4716,8 +4732,8 @@ function App() { setTabDisplayElementRow(key, value as 'primary' | 'secondary')} @@ -4732,7 +4748,7 @@ function App() { moveTabDisplayElement(key, -1); }} > - 上移 + {t('app.theme.tab_display.action.move_up')}
@@ -4750,13 +4766,18 @@ function App() { })}
- 当前预览:{tabDisplaySettings.layout === 'double' ? '主行 ' : ''} - {tabDisplaySettings.primaryElements.map((key) => TAB_DISPLAY_ELEMENT_META[key].label).join(' / ') || '默认标签'} + {t('app.theme.tab_display.preview.prefix')} + {tabDisplaySettings.layout === 'double' ? `${t('app.theme.tab_display.row.primary')} ` : ''} + {tabDisplaySettings.primaryElements.map(getTabDisplayElementLabel).join(' / ') || t('app.theme.tab_display.preview.default_label')} {tabDisplaySettings.layout === 'double' && tabDisplaySettings.secondaryElements.length > 0 - ? `,副行 ${tabDisplaySettings.secondaryElements.map((key) => TAB_DISPLAY_ELEMENT_META[key].label).join(' / ')}` + ? t('app.theme.tab_display.preview.secondary', { + labels: tabDisplaySettings.secondaryElements.map(getTabDisplayElementLabel).join(' / '), + }) : ''} {focusedTabDisplayElementKey - ? `;当前选中 ${TAB_DISPLAY_ELEMENT_META[focusedTabDisplayElementKey].label}` + ? t('app.theme.tab_display.preview.focused', { + label: getTabDisplayElementLabel(focusedTabDisplayElementKey), + }) : ''}
diff --git a/frontend/src/App.ui-version.test.ts b/frontend/src/App.ui-version.test.ts index 26fedf2..17c4d06 100644 --- a/frontend/src/App.ui-version.test.ts +++ b/frontend/src/App.ui-version.test.ts @@ -15,37 +15,48 @@ describe('UI version switch placement', () => { it('keeps the UI version switch in theme mode and outside macOS-only settings', () => { const themeBranchIndex = appSource.indexOf("{themeModalSection === 'theme' ? ("); - const uiVersionIndex = appSource.indexOf('界面版本', themeBranchIndex); - const lightThemeIndex = appSource.indexOf('亮色主题', themeBranchIndex); + const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')", themeBranchIndex); + const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex); const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex); - const macWindowIndex = appSource.indexOf('macOS 窗口控制'); + const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')"); expect(themeBranchIndex).toBeGreaterThan(-1); expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex); expect(uiVersionIndex).toBeLessThan(lightThemeIndex); expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex); expect(macWindowIndex).toBeGreaterThan(uiVersionIndex); - expect(appSource).toContain("badge: '默认'"); - expect(appSource).toContain("badge: 'Beta'"); + expect(appSource).toContain("badge: t('app.theme.ui_version.legacy.badge')"); + expect(appSource).toContain("badge: t('app.theme.ui_version.v2.badge')"); expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}"); - expect(appSource).toContain('新版 UI 仍在 Beta'); - expect(appSource).toContain('Windows、macOS 与 Linux 均可切换'); - expect(appSource).toContain('新版左侧搜索模式'); + expect(appSource).toContain("t('app.theme.ui_version.beta_warning')"); + expect(appSource).toContain("t('app.theme.ui_version.platform_hint')"); + expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')"); expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}"); expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })"); }); it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => { - const uiVersionIndex = appSource.indexOf('界面版本'); - const themeModeIndex = appSource.indexOf('主题模式', uiVersionIndex); + const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')"); + const themeModeIndex = appSource.indexOf("t('app.theme.mode_title')", uiVersionIndex); const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex); - expect(uiVersionBlock).toContain('NEW'); + expect(uiVersionBlock).toContain("t('app.theme.ui_version.badge.new')"); expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'"); - expect(uiVersionBlock).toContain("label: '旧版 UI'"); - expect(uiVersionBlock).toContain("label: '新版 UI'"); + expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.legacy.label')"); + expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.v2.label')"); expect(uiVersionBlock).toContain('CheckOutlined'); - expect(uiVersionBlock).toContain('新版左侧搜索模式'); + expect(uiVersionBlock).toContain("t('app.theme.ui_version.sidebar_search.title')"); expect(uiVersionBlock).toContain(' { + expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')"); + expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.command')"); + expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.filter')"); + expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.hint')"); + expect(appSource).not.toContain('新版左侧搜索模式'); + expect(appSource).not.toContain('新版命令搜索'); + expect(appSource).not.toContain('旧版侧栏筛选'); + expect(appSource).not.toContain('新版命令搜索适合跳转连接、表和动作'); + }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 86fed1d..af57f99 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -122,8 +122,8 @@ export const AIChatPanel: React.FC = ({ onWidthChange, }); const availableTools = useMemo( - () => buildAvailableAIChatTools(mcpTools), - [mcpTools], + () => buildAvailableAIChatTools(mcpTools, t), + [mcpTools, t], ); const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding( state.shortcutOptions, diff --git a/frontend/src/components/AISettingsModal.edit-password.test.tsx b/frontend/src/components/AISettingsModal.edit-password.test.tsx index f5694ee..71f53e3 100644 --- a/frontend/src/components/AISettingsModal.edit-password.test.tsx +++ b/frontend/src/components/AISettingsModal.edit-password.test.tsx @@ -19,6 +19,79 @@ describe('AISettingsModal edit password behavior', () => { expect(source).toContain(' { + expect(source).toContain("messageApi.success(t('ai_settings.prompts.message.saved'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'))"); + expect(source).not.toContain("'自定义提示词已保存'"); + expect(source).not.toContain("'保存自定义提示词失败'"); + }); + + it('localizes MCP server toast fallbacks', () => { + expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.saved'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'))"); + expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.deleted'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'))"); + expect(source).toContain("messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success'))"); + expect(source).toContain("messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'))"); + expect(source).not.toContain("'MCP 服务已保存'"); + expect(source).not.toContain("'保存 MCP 服务失败'"); + expect(source).not.toContain("'MCP 服务已删除'"); + expect(source).not.toContain("'删除 MCP 服务失败'"); + expect(source).not.toContain("'MCP 服务连接成功'"); + expect(source).not.toContain("'MCP 服务测试失败'"); + expect(source).not.toContain("'测试 MCP 服务失败'"); + }); + + it('localizes Skill toast fallbacks', () => { + expect(source).toContain("messageApi.success(t('ai_settings.skill.message.saved'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'))"); + expect(source).toContain("messageApi.success(t('ai_settings.skill.message.deleted'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'))"); + expect(source).not.toContain("'Skill 已保存'"); + expect(source).not.toContain("'保存 Skill 失败'"); + expect(source).not.toContain("'Skill 已删除'"); + expect(source).not.toContain("'删除 Skill 失败'"); + }); + + it('localizes MCP HTTP control and copy fallbacks', () => { + expect(source).toContain("throw new Error(t('ai_settings.clipboard.error.unsupported'))"); + expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'))"); + expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'))"); + expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'))"); + expect(source).toContain("messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'))"); + expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'))"); + expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.url_unavailable'))"); + expect(source).toContain("copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'))"); + expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'))"); + expect(source).toContain("copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'))"); + expect(source).not.toContain("'当前环境不支持复制到剪贴板'"); + expect(source).not.toContain("'当前运行时暂不支持 MCP HTTP 服务控制'"); + expect(source).not.toContain("'当前版本暂不支持启动 MCP HTTP 服务'"); + expect(source).not.toContain("'当前版本暂不支持停止 MCP HTTP 服务'"); + expect(source).not.toContain("'GoNavi MCP HTTP 服务已启动'"); + expect(source).not.toContain("'GoNavi MCP HTTP 服务已停止'"); + expect(source).not.toContain("'切换 GoNavi MCP HTTP 服务失败'"); + expect(source).not.toContain("'当前没有可复制的 MCP HTTP URL'"); + expect(source).not.toContain("'MCP HTTP URL 已复制'"); + expect(source).not.toContain("'请先启动 MCP HTTP 服务生成 Authorization Header'"); + expect(source).not.toContain("'Authorization Header 已复制'"); + }); + + it('localizes MCP HTTP default status fallback', () => { + expect(source).toContain("const defaultMCPHTTPServerStatus = useMemo(() => ({"); + expect(source).toContain("message: t('ai_settings.mcp_http.status.not_running')"); + expect(source).toContain("useState(() => defaultMCPHTTPServerStatus)"); + expect(source).not.toContain("'GoNavi MCP HTTP 服务未启动'"); + }); + + it('localizes Skill required built-in tool option labels', () => { + expect(source).toContain("label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`"); + expect(source).toContain("]), [mcpTools, t]);"); + expect(source).not.toContain("label: `${tool.name} · 内置工具`"); + expect(source).not.toContain("· 内置工具"); + }); + it('loads MCP servers and skills through the AI service', () => { expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()'); expect(source).toContain('Service.AIGetMCPServers?.()'); @@ -78,7 +151,8 @@ describe('AISettingsModal edit password behavior', () => { it('renders in-modal test errors through the local message host', () => { expect(source).toContain('antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body })'); - expect(source).toContain("void messageApi.error(`测试失败: ${res?.message || '未知错误'}`);"); + expect(source).toContain("void messageApi.error(res?.message || t('ai_settings.message.test_failed'))"); + expect(source).not.toContain("`测试失败: ${res?.message || '未知错误'}`"); }); it('keeps long ai settings toast errors wrapped within the modal body', () => { diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 01c0077..fa35078 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -47,7 +47,7 @@ const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = { path: '/mcp', url: 'http://127.0.0.1:8765/mcp', schemaOnly: true, - message: 'GoNavi MCP HTTP 服务未启动', + message: '', }; const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = { @@ -79,13 +79,17 @@ const normalizeMCPHTTPAuthorizationToken = (value: string): string => { const AISettingsModal: React.FC = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { const { t } = useI18n(); + const defaultMCPHTTPServerStatus = useMemo(() => ({ + ...DEFAULT_MCP_HTTP_SERVER_STATUS, + message: t('ai_settings.mcp_http.status.not_running'), + }), [t]); const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState(''); const [safetyLevel, setSafetyLevel] = useState('readonly'); const [contextLevel, setContextLevel] = useState('schema_only'); const [mcpServers, setMCPServers] = useState([]); const [mcpTools, setMCPTools] = useState([]); - const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState(DEFAULT_MCP_HTTP_SERVER_STATUS); + const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState(() => defaultMCPHTTPServerStatus); const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState(DEFAULT_MCP_HTTP_SERVER_DRAFT); const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false); const [skills, setSkills] = useState([]); @@ -115,14 +119,14 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai'; const skillRequiredToolOptions = useMemo(() => ([ ...BUILTIN_AI_TOOL_INFO.map((tool) => ({ - label: `${tool.name} · 内置工具`, + label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`, value: tool.name, })), ...mcpTools.map((tool) => ({ label: `${tool.alias} · ${tool.serverName}`, value: tool.alias, })), - ]), [mcpTools]); + ]), [mcpTools, t]); const resolveAIService = useCallback(async () => { const service = await waitForAIService(); @@ -139,11 +143,11 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const copyTextToClipboard = useCallback(async (text: string, successMessage: string) => { if (typeof navigator?.clipboard?.writeText !== 'function') { - throw new Error('当前环境不支持复制到剪贴板'); + throw new Error(t('ai_settings.clipboard.error.unsupported')); } await navigator.clipboard.writeText(text); void messageApi.success(successMessage); - }, [messageApi]); + }, [messageApi, t]); const { handleCopySelectedMCPConfigPath, @@ -192,7 +196,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo callOrFallback(() => Service.AIGetUserPromptSettings?.(), EMPTY_AI_USER_PROMPT_SETTINGS), callOrFallback(() => Service.AIGetMCPServers?.(), []), callOrFallback(() => Service.AIListMCPTools?.(), []), - callOrFallback(() => Service.AIGetMCPHTTPServerStatus?.(), DEFAULT_MCP_HTTP_SERVER_STATUS), + callOrFallback(() => Service.AIGetMCPHTTPServerStatus?.(), defaultMCPHTTPServerStatus), callOrFallback(() => Service.AIGetSkills?.(), []), callOrFallback(() => Service.AIGetMCPClientInstallStatuses?.(), EMPTY_MCP_CLIENT_STATUSES), ]); @@ -214,7 +218,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes); if (mcpHTTPServerStatusRes) { const nextStatus = { - ...DEFAULT_MCP_HTTP_SERVER_STATUS, + ...defaultMCPHTTPServerStatus, ...mcpHTTPServerStatusRes, }; setMCPHTTPServerStatus(nextStatus); @@ -225,7 +229,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo syncMCPClientStatuses(mcpClientStatusesRes); } } catch (e) { console.warn('Failed to load AI config', e); } - }, [resolveAIService, syncMCPClientStatuses]); + }, [defaultMCPHTTPServerStatus, resolveAIService, syncMCPClientStatuses]); useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]); @@ -424,10 +428,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }; await Service?.AISaveUserPromptSettings?.(payload); setUserPromptSettings(payload); - void messageApi.success('自定义提示词已保存'); + void messageApi.success(t('ai_settings.prompts.message.saved')); window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); } catch (e: any) { - void messageApi.error(e?.message || '保存自定义提示词失败'); + void messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed')); } finally { setLoading(false); } @@ -447,10 +451,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const Service = (window as any).go?.aiservice?.Service; await Service?.AISaveMCPServer?.(server); await loadConfig(); - void messageApi.success('MCP 服务已保存'); + void messageApi.success(t('ai_settings.mcp_server.message.saved')); window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); } catch (e: any) { - void messageApi.error(e?.message || '保存 MCP 服务失败'); + void messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed')); } finally { setLoading(false); } @@ -467,9 +471,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo } else { setMCPServers((prev) => prev.filter((item) => item.id !== id)); } - void messageApi.success('MCP 服务已删除'); + void messageApi.success(t('ai_settings.mcp_server.message.deleted')); } catch (e: any) { - void messageApi.error(e?.message || '删除 MCP 服务失败'); + void messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed')); } finally { setLoading(false); } @@ -481,7 +485,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const Service = (window as any).go?.aiservice?.Service; const res = await Service?.AITestMCPServer?.(server); if (res?.success) { - void messageApi.success(res?.message || 'MCP 服务连接成功'); + void messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success')); if (typeof Service?.AIListMCPTools === 'function') { const nextTools = await Service.AIListMCPTools(); if (Array.isArray(nextTools)) setMCPTools(nextTools); @@ -489,10 +493,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo setMCPTools(res.tools); } } else { - void messageApi.error(res?.message || 'MCP 服务测试失败'); + void messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed')); } } catch (e: any) { - void messageApi.error(e?.message || '测试 MCP 服务失败'); + void messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed')); } finally { setLoading(false); } @@ -503,13 +507,13 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo setMCPHTTPServerLoading(true); const Service = await resolveAIService(); if (!Service) { - throw new Error('当前运行时暂不支持 MCP HTTP 服务控制'); + throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime')); } if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') { - throw new Error('当前版本暂不支持启动 MCP HTTP 服务'); + throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version')); } if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') { - throw new Error('当前版本暂不支持停止 MCP HTTP 服务'); + throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version')); } const nextStatus = checked ? await Service.AIStartMCPHTTPServer({ @@ -521,15 +525,15 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo : await Service.AIStopMCPHTTPServer(); if (nextStatus) { const normalizedStatus = { - ...DEFAULT_MCP_HTTP_SERVER_STATUS, + ...defaultMCPHTTPServerStatus, ...nextStatus, }; setMCPHTTPServerStatus(normalizedStatus); setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev)); } - void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止'); + void messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped')); } catch (e: any) { - void messageApi.error(e?.message || '切换 GoNavi MCP HTTP 服务失败'); + void messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed')); } finally { setMCPHTTPServerLoading(false); } @@ -545,19 +549,19 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const handleCopyMCPHTTPServerURL = async () => { const url = String(mcpHTTPServerStatus.url || '').trim(); if (!url) { - void messageApi.error('当前没有可复制的 MCP HTTP URL'); + void messageApi.error(t('ai_settings.mcp_http.message.url_unavailable')); return; } - await copyTextToClipboard(url, 'MCP HTTP URL 已复制'); + await copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied')); }; const handleCopyMCPHTTPServerAuthorization = async () => { const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim(); if (!authorizationHeader) { - void messageApi.error('请先启动 MCP HTTP 服务生成 Authorization Header'); + void messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required')); return; } - await copyTextToClipboard(`Authorization: ${authorizationHeader}`, 'Authorization Header 已复制'); + await copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied')); }; const updateSkillDraft = (id: string, patch: Partial) => { @@ -574,10 +578,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const Service = (window as any).go?.aiservice?.Service; await Service?.AISaveSkill?.(skill); await loadConfig(); - void messageApi.success('Skill 已保存'); + void messageApi.success(t('ai_settings.skill.message.saved')); window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')); } catch (e: any) { - void messageApi.error(e?.message || '保存 Skill 失败'); + void messageApi.error(e?.message || t('ai_settings.skill.message.save_failed')); } finally { setLoading(false); } @@ -594,9 +598,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo } else { setSkills((prev) => prev.filter((item) => item.id !== id)); } - void messageApi.success('Skill 已删除'); + void messageApi.success(t('ai_settings.skill.message.deleted')); } catch (e: any) { - void messageApi.error(e?.message || '删除 Skill 失败'); + void messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed')); } finally { setLoading(false); } diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 02418f4..68688b5 100644 --- a/frontend/src/components/ConnectionModal.edit-password.test.tsx +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -58,14 +58,14 @@ describe('ConnectionModal data source registry', () => { 'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";', ); expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)'); - expect(source).toContain('label="显示数据库 (留空显示全部)"'); + expect(source).toContain('connection.modal.field.displayDatabases.label'); }); it('keeps MQTT username optional during test-connection validation', () => { expect(source).toContain('"mqtt",'); expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)'); - expect(source).toContain(': [createUriAwareRequiredRule("请输入用户名")]'); - expect(source).toContain('? "未开启认证可留空"'); + expect(source).toContain('connection.modal.field.username.required'); + expect(source).toContain('connection.modal.field.username.optional_placeholder'); }); it('exposes Chroma in the create-connection picker with vector defaults', () => { @@ -114,11 +114,11 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("return 'NameServer / Topic / Consumer Group';"); expect(source).toContain('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";'); expect(source).toContain('return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";'); - expect(source).toContain('label="默认 Topic(可选)"'); - expect(source).toContain('label={dbType === "rocketmq" ? "Access Key" : "用户名"}'); - expect(source).toContain('label={dbType === "rocketmq" ? "Secret Key" : "密码"}'); - expect(source).toContain('emptyPlaceholder: dbType === "rocketmq" ? "未开启认证可留空" : "密码"'); - expect(source).toContain('retainedLabel: dbType === "rocketmq" ? "已保存 Secret Key" : "已保存密码"'); + expect(source).toContain('t("connection.modal.messageQueue.rocketmq.defaultTopic.label")'); + expect(source).toContain('connection.modal.field.username.label'); + expect(source).toContain('connection.modal.field.password.label'); + expect(source).toContain('connection.modal.field.username.optional_placeholder'); + expect(source).toContain('connection.modal.field.password.retained'); }); it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => { @@ -131,7 +131,7 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("return 'Broker / Topic Filter / QoS';"); expect(source).toContain('return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";'); expect(source).toContain('return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";'); - expect(source).toContain('label="默认 Topic / Filter(可选)"'); + expect(source).toContain('t("connection.modal.messageQueue.mqtt.defaultTopicFilter.label")'); }); it('exposes Kafka in the create-connection picker with broker and topic defaults', () => { @@ -143,7 +143,7 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("return 'Broker / Topic / Consumer Group';"); expect(source).toContain('return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";'); expect(source).toContain('return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";'); - expect(source).toContain('label="默认 Topic(可选)"'); + expect(source).toContain('t("connection.modal.messageQueue.kafka.defaultTopic.label")'); }); it('exposes RabbitMQ in the create-connection picker with management-api and vhost defaults', () => { @@ -156,7 +156,7 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("return 'Management API / Virtual Host / Queue';"); expect(source).toContain('return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";'); expect(source).toContain('return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq";'); - expect(source).toContain('label="默认 Virtual Host(可选)"'); + expect(source).toContain('t("connection.modal.messageQueue.rabbitmq.defaultVirtualHost.label")'); }); it('exposes GaussDB in the create-connection picker with PostgreSQL-family defaults', () => { @@ -185,21 +185,52 @@ describe('ConnectionModal data source registry', () => { }); it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => { - expect(source).toContain('OceanBase Oracle 服务名 (Service Name,可选)'); + expect(source).toContain('connection.modal.field.oceanBaseServiceName.label'); expect(source).toContain('isOceanBaseOracle\n ? []'); - expect(source).toContain('连接 OBClient/OBServer MySQL-wire 入口时可留空'); - expect(source).toContain('只有连接 OBProxy Oracle listener/TNS 入口时才需要填写 SERVICE_NAME'); - expect(source).toContain('createUriAwareRequiredRule("请输入 Oracle 服务名(例如 ORCLPDB1)")'); + expect(source).toContain('connection.modal.field.oceanBaseServiceName.help'); + expect(source).toContain('connection.modal.field.serviceName.help'); + expect(source).toContain('connection.modal.field.serviceName.required'); expect(source).not.toContain('请输入 OceanBase Oracle 服务名'); expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME'); }); + + it('uses localized message queue service, topology, and extra host copy', () => { + [ + 'label="默认 Topic(可选)"', + 'label="默认 Topic / Filter(可选)"', + 'label="默认 Virtual Host(可选)"', + 'label: "单 Broker"', + 'label: "单 NameServer"', + 'label="额外 Broker 地址"', + 'label="额外 NameServer 地址"', + 'help="可输入多个 broker 地址,格式:host:port(回车确认)"', + 'help="可输入多个 NameServer 地址,格式:host:port(回车确认)"', + ].forEach((snippet) => { + expect(source).not.toContain(snippet); + }); + + [ + 'connection.modal.messageQueue.kafka.defaultTopic.help', + 'connection.modal.messageQueue.rocketmq.defaultTopic.help', + 'connection.modal.messageQueue.mqtt.defaultTopicFilter.help', + 'connection.modal.messageQueue.rabbitmq.defaultVirtualHost.help', + 'connection.modal.messageQueue.kafka.topology.single.label', + 'connection.modal.messageQueue.rocketmq.topology.single.label', + 'connection.modal.messageQueue.mqtt.topology.cluster.description', + 'connection.modal.messageQueue.kafka.extraBrokers.placeholder', + 'connection.modal.messageQueue.rocketmq.extraNameServers.placeholder', + 'connection.modal.messageQueue.mqtt.extraBrokers.placeholder', + ].forEach((key) => { + expect(source).toContain(key); + }); + }); }); describe('ConnectionModal Redis Sentinel configuration', () => { it('exposes Sentinel topology fields and safe defaults', () => { - expect(source).toContain('label: "哨兵模式"'); + expect(source).toContain('connection.modal.redis.topology.sentinel.label'); expect(source).toContain('name="redisSentinelMaster"'); - expect(source).toContain('Sentinel master 名称'); + expect(source).toContain('connection.modal.redis.sentinel.master.label'); expect(source).toContain('name="redisSentinelPassword"'); expect(source).toContain('hasRedisSentinelPassword'); expect(source).toContain('clearKey: "redisSentinelPassword"'); @@ -207,6 +238,73 @@ describe('ConnectionModal Redis Sentinel configuration', () => { expect(source).toContain('form.setFieldValue("port", 6379)'); }); + it('uses localized Redis topology, sentinel, credential, and database-scope copy', () => { + [ + 'label: "单机模式"', + 'description: "只连接一个 Redis 节点。"', + 'label: "集群模式"', + 'description: "Redis Cluster,配置多个种子节点。"', + 'label: "哨兵模式"', + 'description: "通过 Sentinel 发现主节点,适合主从高可用。"', + '? "Sentinel 附加节点地址"', + ': "集群附加节点地址"', + '? "上方主机地址作为第一个 Sentinel;这里填写其他 Sentinel 节点,格式:host:port"', + ': "主节点使用上方主机地址;这里填写其他种子节点,格式:host:port"', + 'label="Sentinel master 名称"', + 'help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"', + 'label="密码 (可选)"', + 'emptyPlaceholder: "Redis 密码(如果设置了 requirepass)"', + 'retainedLabel: "已保存 Redis 密码"', + 'label="Sentinel 用户名(可选)"', + 'placeholder="留空表示 Sentinel 不使用 ACL 用户名"', + 'label="Sentinel 密码(可选)"', + 'emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送"', + 'retainedLabel: "已保存 Sentinel 密码"', + 'clearLabel: "清除已保存 Sentinel 密码"', + 'label="显示数据库 (留空显示全部)"', + 'help="连接测试成功后可选择"', + 'placeholder="选择显示的数据库"', + ].forEach((snippet) => { + expect(redisSectionsSource).not.toContain(snippet); + }); + + [ + 'connection.modal.redis.topology.single.label', + 'connection.modal.redis.topology.cluster.description', + 'connection.modal.redis.topology.sentinel.label', + 'connection.modal.redis.hosts.sentinel.label', + 'connection.modal.redis.hosts.cluster.help', + 'connection.modal.redis.sentinel.master.required', + 'connection.modal.redis.credentials.primary.placeholder.empty', + 'connection.modal.redis.credentials.sentinelPassword.clear', + 'connection.modal.redis.databaseScope.placeholder', + ].forEach((key) => { + expect(redisSectionsSource).toContain(key); + }); + }); + + it('uses localized Redis test feedback and optional-auth placeholders', () => { + [ + '测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码', + '连接成功但拉取 Redis 数据库列表超时', + '连接成功,但获取 Redis 数据库列表失败', + '未知错误', + '? "未开启认证可留空"', + ].forEach((snippet) => { + expect(connectionModalSource).not.toContain(snippet); + }); + + [ + 'connection.modal.secret.blocking.redis_sentinel', + 'connection.modal.test.redis_database_list_timeout', + 'connection.modal.test.redis_database_list_failure', + 'connection.modal.error.unknown', + 'connection.modal.field.username.optional_placeholder', + ].forEach((key) => { + expect(connectionModalSource).toContain(key); + }); + }); + it('keeps the saved host as the primary Redis node when editing multi-node configs', () => { expect(source).toContain('const savedPrimaryAddress = isFileDbConfigType'); expect(source).toContain('savedPrimaryAddress,'); @@ -220,10 +318,64 @@ describe('ConnectionModal MongoDB configuration', () => { it('keeps replica, SRV, and read preference fields in the split Mongo sections', () => { expect(source).toContain('ConnectionModalMongoSections'); expect(source).toContain('name="mongoSrv"'); - expect(source).toContain('SRV 与 SSH 隧道同时启用'); + expect(source).toContain('connection.modal.mongodb.discovery.srv_ssh_warning'); expect(source).toContain('name="mongoReplicaPassword"'); expect(source).toContain('clearKey: "mongoReplicaPassword"'); - expect(source).toContain('自动发现成员'); + expect(source).toContain('connection.modal.action.discover_members'); expect(source).toContain('fieldName: "mongoReadPreference"'); }); + + it('uses localized MongoDB topology, discovery, replica, and policy copy', () => { + [ + 'label: "单机模式"', + 'description: "只连接一个 MongoDB 节点。"', + 'label: "副本集 / 多节点"', + 'description: "配置副本集名称和多个候选节点。"', + 'label: "标准地址"', + 'description: "使用 host:port 直连或副本集节点列表。"', + 'label: "SRV 地址"', + 'description: "使用 mongodb+srv,由 DNS 发现目标节点。"', + '当前', + 'message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"', + 'label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}', + '? "可输入多个候选主机名,格式:host;若留空则仅使用上方主机。"', + ': "可输入多个节点地址,格式:host:port(回车确认)"', + 'label="副本集名称(可选)"', + 'label="副本集用户名(可选)"', + 'placeholder="留空沿用主用户名"', + 'label="副本集密码(可选)"', + 'emptyPlaceholder: "留空沿用主密码"', + 'retainedLabel: "已保存副本集密码"', + 'clearLabel: "清除已保存副本集密码"', + '当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。', + '自动发现成员', + 'title: "角色"', + 'title: "健康"', + '? "正常" : "异常"', + 'label="认证库 (authSource)"', + 'placeholder="默认使用 database 或 admin"', + '读偏好 (readPreference)', + 'description: "只读主节点。"', + 'description: "主节点优先。"', + 'description: "只读从节点。"', + 'description: "从节点优先。"', + 'description: "选择最近节点。"', + ].forEach((snippet) => { + expect(mongoSectionsSource).not.toContain(snippet); + }); + + [ + 'connection.modal.mongodb.topology.single.label', + 'connection.modal.mongodb.discovery.standard.label', + 'connection.modal.mongodb.discovery.srv_ssh_warning', + 'connection.modal.mongodb.replica.hosts.srv.label', + 'connection.modal.mongodb.replica.password.description', + 'connection.modal.action.discover_members', + 'connection.modal.mongodb.members.role', + 'connection.modal.mongodb.policy.auth_source.label', + 'connection.modal.mongodb.read_preference.primary', + ].forEach((key) => { + expect(mongoSectionsSource).toContain(key); + }); + }); }); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index 6cc9dca..ce38764 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -3361,7 +3361,7 @@ const ConnectionModal: React.FC<{ values.redisTopology === "sentinel" && String(values.redisSentinelPassword ?? "") === "" ) { - return "测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码"; + return t("connection.modal.secret.blocking.redis_sentinel"); } if ( values.type === "mongodb" && @@ -3457,7 +3457,9 @@ const ConnectionModal: React.FC<{ const dbRes = await withClientTimeout( RedisGetDatabases(config as any), rpcTimeoutMs, - `连接成功但拉取 Redis 数据库列表超时(>${timeoutSeconds} 秒)`, + t("connection.modal.test.redis_database_list_timeout", { + seconds: timeoutSeconds, + }), ); if (dbRes.success) { const supportedDbs = extractRedisDatabaseList(dbRes.data); @@ -3477,7 +3479,12 @@ const ConnectionModal: React.FC<{ ), ); message.warning( - `连接成功,但获取 Redis 数据库列表失败:${normalizeConnectionSecretErrorMessage(dbRes.message, "未知错误")}`, + t("connection.modal.test.redis_database_list_failure", { + detail: normalizeConnectionSecretErrorMessage( + dbRes.message, + t("connection.modal.error.unknown"), + ), + }), ); } } else if (!isJVMType) { @@ -5570,11 +5577,16 @@ const ConnectionModal: React.FC<{ children: ( - + ), })} @@ -5586,11 +5598,16 @@ const ConnectionModal: React.FC<{ children: ( - + ), })} @@ -5602,11 +5619,16 @@ const ConnectionModal: React.FC<{ children: ( - + ), })} @@ -5618,11 +5640,16 @@ const ConnectionModal: React.FC<{ children: ( - + ), })} @@ -5707,13 +5734,17 @@ const ConnectionModal: React.FC<{ options: [ { value: "single", - label: "单 Broker", - description: "只配置一个 bootstrap broker,适合本地或简单环境。", + label: t("connection.modal.messageQueue.kafka.topology.single.label"), + description: t( + "connection.modal.messageQueue.kafka.topology.single.description", + ), }, { value: "cluster", - label: "集群模式", - description: "配置多个 bootstrap broker,提高发现与故障切换成功率。", + label: t("connection.modal.messageQueue.topology.cluster.label"), + description: t( + "connection.modal.messageQueue.kafka.topology.cluster.description", + ), }, ], }), @@ -5729,13 +5760,17 @@ const ConnectionModal: React.FC<{ options: [ { value: "single", - label: "单 NameServer", - description: "只配置一个 NameServer,适合本地或简单环境。", + label: t("connection.modal.messageQueue.rocketmq.topology.single.label"), + description: t( + "connection.modal.messageQueue.rocketmq.topology.single.description", + ), }, { value: "cluster", - label: "集群模式", - description: "配置多个 NameServer,提高路由发现与故障切换成功率。", + label: t("connection.modal.messageQueue.topology.cluster.label"), + description: t( + "connection.modal.messageQueue.rocketmq.topology.cluster.description", + ), }, ], }), @@ -5751,13 +5786,17 @@ const ConnectionModal: React.FC<{ options: [ { value: "single", - label: "单 Broker", - description: "只配置一个 broker,适合本地或简单环境。", + label: t("connection.modal.messageQueue.mqtt.topology.single.label"), + description: t( + "connection.modal.messageQueue.mqtt.topology.single.description", + ), }, { value: "cluster", - label: "集群模式", - description: "配置多个 broker,提高连接发现与故障切换成功率。", + label: t("connection.modal.messageQueue.topology.cluster.label"), + description: t( + "connection.modal.messageQueue.mqtt.topology.cluster.description", + ), }, ], }), @@ -5771,12 +5810,14 @@ const ConnectionModal: React.FC<{ children: ( @@ -5811,12 +5854,14 @@ const ConnectionModal: React.FC<{ children: ( +
- - - - - - + {[ + { + value: false, + label: t("connection.modal.mongodb.discovery.standard.label"), + description: t("connection.modal.mongodb.discovery.standard.description"), + }, + { + value: true, + label: t("connection.modal.mongodb.discovery.srv.label"), + description: t("connection.modal.mongodb.discovery.srv.description"), + }, + ].map((option) => { + const active = mongoSrv === option.value; + return ( + + ); + })}
- - - - {renderStoredSecretControls({ - fieldName: "mongoReplicaPassword", - clearKey: "mongoReplicaPassword", - hasStoredSecret: initialValues?.hasMongoReplicaPassword, - clearLabel: "清除已保存副本集密码", - description: - "当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。", - })} - - - - {mongoMembers.length > 0 && ( - record.host} - pagination={false} - dataSource={mongoMembers} - style={{ marginBottom: 12 }} - columns={[ - { title: "Host", dataIndex: "host", width: "48%" }, - { - title: "角色", - dataIndex: "role", - width: "32%", - render: (value: string, record: MongoMemberInfo) => ( - - {value || "UNKNOWN"} - - ), - }, - { - title: "健康", - dataIndex: "healthy", - width: "20%", - render: (value: boolean) => ( - - {value ? "正常" : "异常"} - - ), - }, - ]} + {mongoSrv && useSSH && ( + )} ), })} - {renderConfigSectionCard({ - sectionKey: "mongoPolicy", - icon: , - children: ( -
- , + children: ( + <> + + + + + + +
+ + + + {renderStoredSecretControls({ + fieldName: "mongoReplicaPassword", + clearKey: "mongoReplicaPassword", + hasStoredSecret: initialValues?.hasMongoReplicaPassword, + clearLabel: t("connection.modal.mongodb.replica.password.clear"), + description: t("connection.modal.mongodb.replica.password.description"), + })} + + + + {mongoMembers.length > 0 && ( +
record.host} + pagination={false} + dataSource={mongoMembers} + style={{ marginBottom: 12 }} + columns={[ + { title: "Host", dataIndex: "host", width: "48%" }, + { + title: t("connection.modal.mongodb.members.role"), + dataIndex: "role", + width: "32%", + render: (value: string, record: MongoMemberInfo) => ( + + {value || "UNKNOWN"} + + ), + }, + { + title: t("connection.modal.mongodb.members.health"), + dataIndex: "healthy", + width: "20%", + render: (value: boolean) => ( + + {t( + value + ? "connection.modal.mongodb.members.health.ok" + : "connection.modal.mongodb.members.health.error", + )} + + ), + }, + ]} + /> + )} + + ), + })} + + {renderConfigSectionCard({ + sectionKey: "mongoPolicy", + icon: , + children: ( +
- - -
- 读偏好 (readPreference) - {renderChoiceCards({ - fieldName: "mongoReadPreference", - value: String(mongoReadPreference), - minWidth: 130, - options: [ - { - value: "primary", - label: "primary", - description: "只读主节点。", - }, - { - value: "primaryPreferred", - label: "primaryPreferred", - description: "主节点优先。", - }, - { - value: "secondary", - label: "secondary", - description: "只读从节点。", - }, - { - value: "secondaryPreferred", - label: "secondaryPreferred", - description: "从节点优先。", - }, - { - value: "nearest", - label: "nearest", - description: "选择最近节点。", - }, - ], - })} + + + +
+ {t("connection.modal.mongodb.read_preference")} + {renderChoiceCards({ + fieldName: "mongoReadPreference", + value: String(mongoReadPreference), + minWidth: 130, + options: [ + { + value: "primary", + label: "primary", + description: t("connection.modal.mongodb.read_preference.primary"), + }, + { + value: "primaryPreferred", + label: "primaryPreferred", + description: t( + "connection.modal.mongodb.read_preference.primary_preferred", + ), + }, + { + value: "secondary", + label: "secondary", + description: t("connection.modal.mongodb.read_preference.secondary"), + }, + { + value: "secondaryPreferred", + label: "secondaryPreferred", + description: t( + "connection.modal.mongodb.read_preference.secondary_preferred", + ), + }, + { + value: "nearest", + label: "nearest", + description: t("connection.modal.mongodb.read_preference.nearest"), + }, + ], + })} +
-
- ), - })} - -); + ), + })} + + ); +}; export default ConnectionModalMongoSections; diff --git a/frontend/src/components/ConnectionModalRedisSections.tsx b/frontend/src/components/ConnectionModalRedisSections.tsx index 33a794e..fc40a68 100644 --- a/frontend/src/components/ConnectionModalRedisSections.tsx +++ b/frontend/src/components/ConnectionModalRedisSections.tsx @@ -11,6 +11,7 @@ import { getStoredSecretPlaceholder, type ConnectionConfigSectionKey, } from "../utils/connectionModalPresentation"; +import { useI18n } from "../i18n/provider"; import { noAutoCapInputProps } from "../utils/inputAutoCap"; type ChoiceCardOption = { @@ -67,176 +68,185 @@ const ConnectionModalRedisSections: React.FC renderConfigSectionCard, renderStoredSecretControls, createUriAwareRequiredRule, -}) => ( - <> - {renderConfigSectionCard({ - sectionKey: "connectionMode", - icon: , - children: ( - <> - {renderChoiceCards({ - fieldName: "redisTopology", - value: String(redisTopology), - options: [ - { - value: "single", - label: "单机模式", - description: "只连接一个 Redis 节点。", - }, - { - value: "cluster", - label: "集群模式", - description: "Redis Cluster,配置多个种子节点。", - }, - { - value: "sentinel", - label: "哨兵模式", - description: "通过 Sentinel 发现主节点,适合主从高可用。", - }, - ], - })} - {(redisTopology === "cluster" || redisTopology === "sentinel") && ( - <> - - - )} - - )} - - ), - })} + {redisTopology === "sentinel" && ( + + + + )} + + )} + + ), + })} - {renderConfigSectionCard({ - sectionKey: "credentials", - icon: , - children: ( - <> - - - - {redisTopology === "sentinel" && ( - <> -
, + children: ( + <> + + - + + {redisTopology === "sentinel" && ( + <> +
- - - - - -
- {renderStoredSecretControls({ - fieldName: "redisSentinelPassword", - clearKey: "redisSentinelPassword", - hasStoredSecret: initialValues?.hasRedisSentinelPassword, - clearLabel: "清除已保存 Sentinel 密码", - description: - "当前已保存 Sentinel 密码。留空表示继续沿用,输入新值表示替换。", - })} - - )} - - ), - })} + + + + + + +
+ {renderStoredSecretControls({ + fieldName: "redisSentinelPassword", + clearKey: "redisSentinelPassword", + hasStoredSecret: initialValues?.hasRedisSentinelPassword, + clearLabel: t("connection.modal.redis.credentials.sentinelPassword.clear"), + description: t( + "connection.modal.redis.credentials.sentinelPassword.description", + ), + })} + + )} + + ), + })} - {renderConfigSectionCard({ - sectionKey: "databaseScope", - icon: , - children: ( - - - - ), - })} - -); + + + ), + })} + + ); +}; export default ConnectionModalRedisSections; diff --git a/frontend/src/components/DataGrid.auto-commit-delay.i18n.test.ts b/frontend/src/components/DataGrid.auto-commit-delay.i18n.test.ts new file mode 100644 index 0000000..acabc16 --- /dev/null +++ b/frontend/src/components/DataGrid.auto-commit-delay.i18n.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + +describe('DataGrid auto commit delay i18n guards', () => { + it('localizes auto commit delay option labels', () => { + expect(dataGridSource).toContain("translateDataGrid('data_grid.toolbar.commit_delay.seconds', { seconds: item.seconds })"); + + [ + "label: '3 秒'", + "label: '5 秒'", + "label: '10 秒'", + "label: '30 秒'", + ].forEach((legacyText) => { + expect(dataGridSource).not.toContain(legacyText); + }); + }); +}); diff --git a/frontend/src/components/DataGrid.auto-commit.i18n.test.ts b/frontend/src/components/DataGrid.auto-commit.i18n.test.ts new file mode 100644 index 0000000..6c03168 --- /dev/null +++ b/frontend/src/components/DataGrid.auto-commit.i18n.test.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + +describe('DataGrid auto commit i18n guards', () => { + it('localizes auto commit toast wrappers while preserving raw details', () => { + expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_success')"); + expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_failed', { detail: res.message })"); + + expect(dataGridSource).not.toContain("'自动提交成功'"); + expect(dataGridSource).not.toContain('`自动提交失败: ${res.message}`'); + }); +}); diff --git a/frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts b/frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts new file mode 100644 index 0000000..a949bdc --- /dev/null +++ b/frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts @@ -0,0 +1,17 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const legacyMenuSource = readFileSync(new URL('./DataGridLegacyCellContextMenu.tsx', import.meta.url), 'utf8'); +const v2MenuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8'); + +describe('DataGrid cell undo menu i18n guards', () => { + it('localizes cell undo action labels in legacy and v2 menus', () => { + [ + legacyMenuSource, + v2MenuSource, + ].forEach((source) => { + expect(source).toContain("data_grid.context_menu.undo_cell_change"); + expect(source).not.toContain('撤销此单元格修改'); + }); + }); +}); diff --git a/frontend/src/components/DataGrid.cell-undo.i18n.test.ts b/frontend/src/components/DataGrid.cell-undo.i18n.test.ts new file mode 100644 index 0000000..e1c11a4 --- /dev/null +++ b/frontend/src/components/DataGrid.cell-undo.i18n.test.ts @@ -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'); + +describe('DataGrid cell undo i18n guards', () => { + it('localizes cell undo toast wrappers', () => { + [ + "translateDataGrid('data_grid.message.undo_added_row_hint')", + "translateDataGrid('data_grid.message.undo_cell_original_missing')", + "translateDataGrid('data_grid.message.undo_cell_success')", + ].forEach((expected) => { + expect(dataGridSource).toContain(expected); + }); + + [ + '新增行请使用删除选中或整表回滚撤销', + '未找到该单元格的原始数据,无法撤销', + '已撤销单元格修改', + ].forEach((legacyText) => { + expect(dataGridSource).not.toContain(legacyText); + }); + }); +}); diff --git a/frontend/src/components/DataGrid.embedded-designer-title.i18n.test.ts b/frontend/src/components/DataGrid.embedded-designer-title.i18n.test.ts new file mode 100644 index 0000000..0439370 --- /dev/null +++ b/frontend/src/components/DataGrid.embedded-designer-title.i18n.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + +describe('DataGrid embedded designer title i18n guards', () => { + it('localizes the embedded table designer tab title while preserving the raw table name parameter', () => { + expect(dataGridSource).toContain("translateDataGrid('data_grid.embedded_designer.title'"); + expect(dataGridSource).toContain('tableName: tableName ||'); + expect(dataGridSource).not.toContain('title: `设计表 (${tableName || \'\'}'); + }); + + it('keeps the embedded designer title key in every locale catalog with the tableName placeholder', () => { + (['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => { + const catalog = JSON.parse( + readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'), + ) as Record; + + expect(catalog['data_grid.embedded_designer.title']).toEqual(expect.any(String)); + expect(catalog['data_grid.embedded_designer.title']).toContain('{{tableName}}'); + }); + }); +}); diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 1a2c6a1..e108d1f 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -104,6 +104,15 @@ const renderDataGridWithI18n = ( ); }; +const zhCnCatalog = JSON.parse( + readFileSync(new URL('../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8'), +) as Record; +const enUsCatalog = JSON.parse( + readFileSync(new URL('../../../shared/i18n/en-US.json', import.meta.url), 'utf8'), +) as Record; +const zhObjectDesignLabel = zhCnCatalog['data_grid.secondary.object_design']; +const enUndoCellChangeLabel = enUsCatalog['data_grid.context_menu.undo_cell_change']; + describe('DataGrid layout', () => { it('renders a secondary action strip for view switching and auxiliary actions', () => { const markup = renderDataGridWithI18n( @@ -136,7 +145,7 @@ describe('DataGrid layout', () => { expect(markup).toContain('data-grid-column-quick-find-action="true"'); expect(markup).toContain('字段显示'); expect(markup).toContain('跳列'); - expect(markup).toContain('对象设计'); + expect(markup).toContain(zhObjectDesignLabel); expect(markup).toContain('data-grid-page-find="true"'); expect(markup).toContain('data-grid-page-find-prev="true"'); expect(markup).toContain('data-grid-page-find-next="true"'); @@ -788,7 +797,7 @@ describe('DataGrid layout', () => { }); it('keeps the v2 footer fields action labeled as field info for views', () => { - const markup = renderToStaticMarkup( + const markup = renderDataGridWithI18n( { ); expect(markup).toContain('字段信息'); - expect(markup).not.toContain('对象设计'); + expect(markup).not.toContain(zhObjectDesignLabel); }); it('falls back to the current i18n language when rendered outside I18nProvider', () => { @@ -1896,7 +1905,7 @@ describe('DataGrid layout', () => { />, ); - expect(markup).toContain('撤销此单元格修改'); + expect(markup).toContain(enUndoCellChangeLabel); }); it('preserves fractional seconds when rendering datetime values', () => { @@ -1977,7 +1986,7 @@ describe('DataGrid layout', () => { expect(tableMarkup).toContain('data-grid-ddl-action="true"'); expect(tableMarkup).toContain('查看 DDL'); - expect(tableMarkup).toContain('对象设计'); + expect(tableMarkup).toContain(zhObjectDesignLabel); expect(tableMarkup).not.toContain('data-grid-locate-sidebar-action="true"'); const schemaTableMarkup = renderDataGridWithI18n( @@ -1999,7 +2008,7 @@ describe('DataGrid layout', () => { expect(schemaTableMarkup).toContain('data-grid-ddl-action="true"'); expect(schemaTableMarkup).toContain('查看 DDL'); - expect(schemaTableMarkup).toContain('对象设计'); + expect(schemaTableMarkup).toContain(zhObjectDesignLabel); expect(schemaTableMarkup).toContain('data-grid-page-find="true"'); const queryMarkup = renderDataGridWithI18n( @@ -2022,7 +2031,7 @@ describe('DataGrid layout', () => { expect(queryMarkup).not.toContain('data-grid-ddl-action="true"'); expect(queryMarkup).toContain('字段信息'); - expect(queryMarkup).not.toContain('对象设计'); + expect(queryMarkup).not.toContain(zhObjectDesignLabel); }); it('keeps row copy and paste as context menu actions instead of toolbar buttons', () => { @@ -2095,6 +2104,7 @@ describe('DataGrid layout', () => { 'data_grid.export.selected_rows', 'data_grid.export.current_page_rows', 'data_grid.export.all_rows', + 'data_grid.export.all_rows_requery', 'data_grid.export.options_title', 'data_grid.export.no_selection_prompt', 'data_grid.export.current_page', diff --git a/frontend/src/components/DataGrid.secondary-actions.i18n.test.ts b/frontend/src/components/DataGrid.secondary-actions.i18n.test.ts new file mode 100644 index 0000000..38c0038 --- /dev/null +++ b/frontend/src/components/DataGrid.secondary-actions.i18n.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8'); + +describe('DataGrid secondary actions i18n guards', () => { + it('localizes the object design action label', () => { + expect(secondaryActionsSource).toContain("translate('data_grid.secondary.object_design')"); + expect(secondaryActionsSource).not.toContain("'对象设计'"); + expect(secondaryActionsSource).not.toContain('>对象设计<'); + }); +}); diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index bc5545f..2504a11 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -199,10 +199,10 @@ const CELL_SELECTION_DRAG_THRESHOLD_PX = 4; const DATE_TIME_CACHE_LIMIT = 2000; const TABLE_CELL_PREVIEW_MAX_CHARS = 240; const DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS = [ - { value: 3000, label: '3 秒' }, - { value: 5000, label: '5 秒' }, - { value: 10000, label: '10 秒' }, - { value: 30000, label: '30 秒' }, + { value: 3000, seconds: 3 }, + { value: 5000, seconds: 5 }, + { value: 10000, seconds: 10 }, + { value: 30000, seconds: 30 }, ]; const DATA_GRID_DISPLAY_RENDER_VERSION = Symbol('DATA_GRID_DISPLAY_RENDER_VERSION'); const DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION = Symbol('DATA_GRID_VIRTUAL_EDIT_RENDER_VERSION'); @@ -1549,6 +1549,13 @@ const DataGrid: React.FC = ({ }, [language] ); + const localizedDataEditAutoCommitDelayOptions = useMemo( + () => DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS.map((item) => ({ + value: item.value, + label: translateDataGrid('data_grid.toolbar.commit_delay.seconds', { seconds: item.seconds }), + })), + [translateDataGrid] + ); const rowLocatorMessages = useMemo(() => ({ noSafeLocator: () => translateDataGrid('data_grid.message.no_safe_locator'), emptyLocatorValue: (column: string) => translateDataGrid('data_grid.message.locator_column_value_empty', { column }), @@ -4419,7 +4426,7 @@ const DataGrid: React.FC = ({ const keyStr = rowKeyStr(rowKey); if (addedRowKeySet.has(keyStr)) { - void message.info('新增行请使用删除选中或整表回滚撤销'); + void message.info(translateDataGrid('data_grid.message.undo_added_row_hint')); setCellContextMenu(prev => ({ ...prev, visible: false })); return; } @@ -4430,15 +4437,15 @@ const DataGrid: React.FC = ({ const originalRow = data.find((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === keyStr); if (!originalRow) { - void message.error('未找到该单元格的原始数据,无法撤销'); + void message.error(translateDataGrid('data_grid.message.undo_cell_original_missing')); setCellContextMenu(prev => ({ ...prev, visible: false })); return; } handleCellSave({ ...record, [dataIndex]: originalRow[dataIndex] }); setCellContextMenu(prev => ({ ...prev, visible: false })); - void message.success('已撤销单元格修改'); - }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr]); + void message.success(translateDataGrid('data_grid.message.undo_cell_success')); + }, [addedRowKeySet, cellContextMenu.dataIndex, cellContextMenu.record, data, handleCellSave, modifiedColumns, rowKeyStr, translateDataGrid]); const handleCellEditorSave = useCallback(() => { if (!cellEditorMeta) return; @@ -5465,7 +5472,9 @@ const DataGrid: React.FC = ({ message: res.message, dbName }); - void message.success(source === 'auto' ? '自动提交成功' : translateDataGrid('data_grid.message.transaction_committed')); + void message.success(source === 'auto' + ? translateDataGrid('data_grid.message.auto_commit_success') + : translateDataGrid('data_grid.message.transaction_committed')); setAddedRows([]); setModifiedRows({}); setDeletedRowKeys(new Set()); @@ -5485,7 +5494,7 @@ const DataGrid: React.FC = ({ autoCommitFailedTokenRef.current = autoCommitChangeTokenRef.current; } void message.error(source === 'auto' - ? `自动提交失败: ${res.message}` + ? translateDataGrid('data_grid.message.auto_commit_failed', { detail: res.message }) : translateDataGrid('data_grid.message.commit_failed', { detail: res.message })); } }, [ @@ -6112,7 +6121,9 @@ const DataGrid: React.FC = ({ {translateDataGrid('data_grid.export.current_page_rows', { count: queryResultCurrentPageRows.length })} @@ -7696,7 +7707,7 @@ const DataGrid: React.FC = ({ pendingChangeCount={pendingChangeCount} dataEditCommitMode={dataEditCommitMode} dataEditAutoCommitDelayMs={dataEditAutoCommitDelayMs} - dataEditAutoCommitDelayOptions={DATA_EDIT_AUTO_COMMIT_DELAY_OPTIONS} + dataEditAutoCommitDelayOptions={localizedDataEditAutoCommitDelayOptions} autoCommitRemainingSeconds={autoCommitRemainingSeconds} canImport={canImport} canExport={canExport} @@ -7841,7 +7852,7 @@ const DataGrid: React.FC = ({ embedded tab={{ id: `embedded-design-${connectionId || ''}-${dbName || ''}-${tableName || ''}`, - title: `设计表 (${tableName || ''})`, + title: translateDataGrid('data_grid.embedded_designer.title', { tableName: tableName || '' }), type: 'design', connectionId: String(connectionId || ''), dbName, diff --git a/frontend/src/components/DataGridLegacyCellContextMenu.tsx b/frontend/src/components/DataGridLegacyCellContextMenu.tsx index a5a47f1..0ebd85e 100644 --- a/frontend/src/components/DataGridLegacyCellContextMenu.tsx +++ b/frontend/src/components/DataGridLegacyCellContextMenu.tsx @@ -156,7 +156,7 @@ const DataGridLegacyCellContextMenu: React.FC - 撤销此单元格修改 + {translate('data_grid.context_menu.undo_cell_change')}
{translate('data_grid.batch_fill.set_null')} diff --git a/frontend/src/components/DataGridSecondaryActions.tsx b/frontend/src/components/DataGridSecondaryActions.tsx index 4e90cb5..3eb7d98 100644 --- a/frontend/src/components/DataGridSecondaryActions.tsx +++ b/frontend/src/components/DataGridSecondaryActions.tsx @@ -61,7 +61,7 @@ const DataGridSecondaryActions: React.FC = ({ }) => { if (isV2Ui) { const fieldsActionLabel = canOpenObjectDesigner - ? '对象设计' + ? translate('data_grid.secondary.object_design') : translate('data_grid.column_settings.field_info'); const fieldsActionIcon = canOpenObjectDesigner ? : ; const viewTabItems: Array<{ key: GridViewMode; label: string; icon: React.ReactNode; disabled?: boolean }> = [ diff --git a/frontend/src/components/DataGridToolbarFrame.i18n.test.ts b/frontend/src/components/DataGridToolbarFrame.i18n.test.ts new file mode 100644 index 0000000..16f870d --- /dev/null +++ b/frontend/src/components/DataGridToolbarFrame.i18n.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const toolbarSource = readFileSync(new URL('./DataGridToolbarFrame.tsx', import.meta.url), 'utf8'); + +describe('DataGridToolbarFrame i18n guards', () => { + it('localizes data edit commit mode controls', () => { + [ + 'data_grid.toolbar.commit_mode.tooltip', + 'data_grid.toolbar.commit_mode.manual', + 'data_grid.toolbar.commit_mode.auto', + 'data_grid.toolbar.commit_mode.auto_countdown', + ].forEach((key) => { + expect(toolbarSource).toContain(`translate('${key}'`); + }); + + [ + '控制表数据编辑后的提交方式', + "label: '手动提交'", + "label: '自动提交'", + 's 后提交', + ].forEach((legacyText) => { + expect(toolbarSource).not.toContain(legacyText); + }); + }); +}); diff --git a/frontend/src/components/DataGridToolbarFrame.tsx b/frontend/src/components/DataGridToolbarFrame.tsx index 31fc08b..f7b3305 100644 --- a/frontend/src/components/DataGridToolbarFrame.tsx +++ b/frontend/src/components/DataGridToolbarFrame.tsx @@ -379,15 +379,15 @@ const DataGridToolbarFrame: React.FC = ({ )} {hasChanges && } - + - - + + )} @@ -1007,9 +1007,9 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: message={isMigrationWorkflow ? tr('data_sync.alert.migration_mode') : isSchemaCompareEntry - ? '当前为“表结构比对”入口:固定只分析结构差异和生成可审阅 SQL,不执行变更。' + ? tr('data_sync.compare_entry.alert.schema') : isDataCompareEntry - ? '当前为“数据比对”入口:固定按主键分析行级差异,不执行写入。' + ? tr('data_sync.compare_entry.alert.data') : tr('data_sync.alert.sync_mode')} /> {isSourceQueryMode && ( @@ -1067,7 +1067,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: setAutoAddColumns(e.target.checked)} disabled={isSourceQueryMode}> {isSchemaCompareEntry - ? '生成目标表缺失字段的兼容变更 SQL(仅预览,不执行)' + ? tr('data_sync.compare_entry.option.auto_add_columns') : tr('data_sync.option.auto_add_columns')} @@ -1300,20 +1300,25 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?:
void; entryMode?:
- {isCompareEntry ? '分析日志' : tr('data_sync.title.execution_log')} + {isCompareEntry ? tr('data_sync.compare_entry.title.analysis_log') : tr('data_sync.title.execution_log')}
{ @@ -1394,7 +1399,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: )} {currentStep === 2 && ( <> - + )} @@ -1471,7 +1476,7 @@ const DataSyncModal: React.FC<{ open: boolean; onClose: () => void; entryMode?: label: tr('data_sync.preview.tab.insert', { count: previewData.totalInserts || 0 }), children: (
- {isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : tr('data_sync.preview.selection_hint.insert')} + {isCompareEntry ? tr('data_sync.compare_entry.preview.selection_hint') : tr('data_sync.preview.selection_hint.insert')}
void; entryMode?: label: tr('data_sync.preview.tab.update', { count: previewData.totalUpdates || 0 }), children: (
- {isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : tr('data_sync.preview.selection_hint.update')} + {isCompareEntry ? tr('data_sync.compare_entry.preview.selection_hint') : tr('data_sync.preview.selection_hint.update')}
void; entryMode?: children: (
- {isCompareEntry ? '行选择只影响 SQL 预览范围,不会执行写入。' : tr('data_sync.preview.selection_hint.delete')} + {isCompareEntry ? tr('data_sync.compare_entry.preview.selection_hint') : tr('data_sync.preview.selection_hint.delete')}
void; entryMode?: showIcon message={ previewHasDataDiff - ? (isCompareEntry ? 'SQL 预览会按当前勾选的插入/更新/删除与行选择范围生成,仅用于审核差异。' : tr('data_sync.preview.sql.data_help')) - : (isCompareEntry ? 'SQL 预览展示结构差异建议语句,仅用于审核差异。' : tr('data_sync.preview.sql.schema_help')) + ? (isCompareEntry ? tr('data_sync.compare_entry.preview.sql.data_help') : tr('data_sync.preview.sql.data_help')) + : (isCompareEntry ? tr('data_sync.compare_entry.preview.sql.schema_help') : tr('data_sync.preview.sql.schema_help')) } />
diff --git a/frontend/src/components/LinuxCJKFontBanner.tsx b/frontend/src/components/LinuxCJKFontBanner.tsx index cc8f750..982eb66 100644 --- a/frontend/src/components/LinuxCJKFontBanner.tsx +++ b/frontend/src/components/LinuxCJKFontBanner.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Button } from 'antd'; import { InfoCircleOutlined } from '@ant-design/icons'; +import { useI18n } from '../i18n/provider'; interface LinuxCJKFontBannerProps { darkMode: boolean; @@ -14,49 +15,53 @@ const LinuxCJKFontBanner: React.FC = ({ installHint, onOpenFontSettings, onDismiss, -}) => ( -
- -
-
- Linux CJK fonts missing / Ubuntu 中文字体缺失 -
-
- Chinese text may render as □□□. Install fonts, then restart GoNavi: - - {installHint} - +}) => { + const { t } = useI18n(); + + return ( +
+ +
+
+ {t('app.linux_cjk_font_banner.title')} +
+
+ {t('app.linux_cjk_font_banner.description')} + + {installHint} + +
+ +
- - -
-); + ); +}; export default LinuxCJKFontBanner; diff --git a/frontend/src/components/MessagePublishModal.i18n.test.ts b/frontend/src/components/MessagePublishModal.i18n.test.ts new file mode 100644 index 0000000..ef03aae --- /dev/null +++ b/frontend/src/components/MessagePublishModal.i18n.test.ts @@ -0,0 +1,166 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const modalSource = readFileSync(new URL('./MessagePublishModal.tsx', import.meta.url), 'utf8'); + +describe('MessagePublishModal i18n shell guards', () => { + it('localizes the modal shell and send failure wrappers while preserving raw details', () => { + [ + 'message_publish_modal.title', + 'message_publish_modal.title_with_connection', + 'message_publish_modal.action.send', + 'message_publish_modal.error.build_command_failed', + 'message_publish_modal.error.send_failed_detail', + 'message_publish_modal.error.unknown_error', + ].forEach((key) => { + expect(modalSource).toContain(`t('${key}'`); + }); + + expect(modalSource).toContain('connectionName: connection.name'); + expect(modalSource).toContain('detail: res?.message'); + expect(modalSource).toContain('detail: error?.message || String(error)'); + expect(modalSource).not.toContain('测试发送消息'); + expect(modalSource).not.toContain('okText="发送"'); + expect(modalSource).not.toContain('发送失败:'); + expect(modalSource).not.toContain('未知错误'); + expect(modalSource).not.toContain('构造发送命令失败'); + }); + + it('localizes the fixed form chrome without translating raw protocol terms', () => { + [ + 'message_publish_modal.field.exchange.label', + 'message_publish_modal.field.exchange.extra', + 'message_publish_modal.field.exchange.placeholder', + 'message_publish_modal.field.routing_key.label', + 'message_publish_modal.field.routing_key.extra', + 'message_publish_modal.field.routing_key.placeholder', + 'message_publish_modal.field.qos.extra', + 'message_publish_modal.field.retain.label', + 'message_publish_modal.field.tag.label', + 'message_publish_modal.field.tag.extra', + 'message_publish_modal.field.delay_level.label', + 'message_publish_modal.field.delay_level.extra', + 'message_publish_modal.field.body_mode.label', + 'message_publish_modal.field.body.label', + 'message_publish_modal.field.body.required', + 'message_publish_modal.field.body.extra', + 'message_publish_modal.field.body.placeholder', + 'message_publish_modal.field.headers.label', + 'message_publish_modal.field.headers.extra', + 'message_publish_modal.field.properties.label', + 'message_publish_modal.field.properties.extra', + 'message_publish_modal.option.no_delay', + 'message_publish_modal.option.text', + 'message_publish_modal.footer.success_prefix', + 'message_publish_modal.footer.success_suffix', + ].forEach((key) => { + expect(modalSource).toContain(`t('${key}'`); + }); + + [ + '不延时', + 'Exchange(可选)', + '留空使用默认交换机', + 'Routing Key(可选)', + '留空时默认使用当前 Queue 名', + '0 为至多一次', + 'Retain 消息', + 'Tag(可选)', + 'Delay Level(可选)', + 'RocketMQ 使用固定延时级别', + '文本', + '消息体类型', + '消息体', + '请输入消息体', + 'JSON 模式下需输入合法 JSON', + 'Headers(可选)', + '需为 JSON 对象', + 'Properties(可选)', + '发送成功后会返回', + '用于确认本次测试消息是否已提交', + ].forEach((legacyText) => { + expect(modalSource).not.toContain(legacyText); + }); + + expect(modalSource).toContain('affectedRows'); + }); + + it('keeps the modal shell keys in every locale catalog with matching placeholders', () => { + (['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => { + const catalog = JSON.parse( + readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'), + ) as Record; + + [ + 'message_publish_modal.title', + 'message_publish_modal.action.send', + 'message_publish_modal.error.build_command_failed', + 'message_publish_modal.error.unknown_error', + ].forEach((key) => { + expect(catalog[key]).toEqual(expect.any(String)); + expect(catalog[key].length).toBeGreaterThan(0); + }); + + expect(catalog['message_publish_modal.title_with_connection']).toContain('{{connectionName}}'); + expect(catalog['message_publish_modal.error.send_failed_detail']).toContain('{{detail}}'); + }); + }); + + it('keeps the fixed form chrome keys in every locale catalog', () => { + (['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => { + const catalog = JSON.parse( + readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'), + ) as Record; + + [ + 'message_publish_modal.field.exchange.label', + 'message_publish_modal.field.exchange.extra', + 'message_publish_modal.field.exchange.placeholder', + 'message_publish_modal.field.routing_key.label', + 'message_publish_modal.field.routing_key.extra', + 'message_publish_modal.field.routing_key.placeholder', + 'message_publish_modal.field.qos.extra', + 'message_publish_modal.field.retain.label', + 'message_publish_modal.field.tag.label', + 'message_publish_modal.field.tag.extra', + 'message_publish_modal.field.delay_level.label', + 'message_publish_modal.field.delay_level.extra', + 'message_publish_modal.field.body_mode.label', + 'message_publish_modal.field.body.label', + 'message_publish_modal.field.body.required', + 'message_publish_modal.field.body.extra', + 'message_publish_modal.field.body.placeholder', + 'message_publish_modal.field.headers.label', + 'message_publish_modal.field.headers.extra', + 'message_publish_modal.field.properties.label', + 'message_publish_modal.field.properties.extra', + 'message_publish_modal.option.no_delay', + 'message_publish_modal.option.text', + 'message_publish_modal.footer.success_prefix', + 'message_publish_modal.footer.success_suffix', + ].forEach((key) => { + expect(catalog[key]).toEqual(expect.any(String)); + expect(catalog[key].length).toBeGreaterThan(0); + }); + + [ + ['message_publish_modal.field.exchange.label', 'Exchange'], + ['message_publish_modal.field.routing_key.label', 'Routing Key'], + ['message_publish_modal.field.qos.extra', 'at most once'], + ['message_publish_modal.field.qos.extra', 'at least once'], + ['message_publish_modal.field.qos.extra', 'exactly once'], + ['message_publish_modal.field.retain.label', 'Retain'], + ['message_publish_modal.field.tag.label', 'Tag'], + ['message_publish_modal.field.delay_level.label', 'Delay Level'], + ['message_publish_modal.field.delay_level.extra', 'RocketMQ'], + ['message_publish_modal.field.body.extra', 'JSON'], + ['message_publish_modal.field.headers.label', 'Headers'], + ['message_publish_modal.field.properties.label', 'Properties'], + ['message_publish_modal.field.headers.extra', '{{example}}'], + ['message_publish_modal.field.properties.extra', '{{example}}'], + ].forEach(([key, rawTerm]) => { + expect(catalog[key]).toContain(rawTerm); + }); + }); + }); +}); diff --git a/frontend/src/components/MessagePublishModal.tsx b/frontend/src/components/MessagePublishModal.tsx index 85571f2..a784217 100644 --- a/frontend/src/components/MessagePublishModal.tsx +++ b/frontend/src/components/MessagePublishModal.tsx @@ -3,6 +3,7 @@ import { Alert, Checkbox, Form, Input, Modal, Select, Space, Typography, message import { DBQuery } from '../../wailsjs/go/app/App'; import type { SavedConnection } from '../types'; +import { useI18n } from '../i18n/provider'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { buildMessagePublishCommand, @@ -15,7 +16,6 @@ const { Text } = Typography; const { TextArea } = Input; const ROCKETMQ_DELAY_LEVEL_OPTIONS = [ - { label: '不延时', value: 0 }, { label: '1 · 1s', value: 1 }, { label: '2 · 5s', value: 2 }, { label: '3 · 10s', value: 3 }, @@ -53,11 +53,12 @@ const MessagePublishModal: React.FC = ({ onCancel, onSuccess, }) => { + const { t } = useI18n(); const [form] = Form.useForm(); const [submitting, setSubmitting] = useState(false); const presentation = useMemo( - () => getMessagePublishPresentation(connection?.config), - [connection], + () => getMessagePublishPresentation(connection?.config, t), + [connection, t], ); useEffect(() => { @@ -88,9 +89,9 @@ const MessagePublishModal: React.FC = ({ let command; try { - command = buildMessagePublishCommand(connection.config, values); + command = buildMessagePublishCommand(connection.config, values, t); } catch (error: any) { - void message.error(error?.message || '构造发送命令失败'); + void message.error(error?.message || t('message_publish_modal.error.build_command_failed')); return; } @@ -102,7 +103,9 @@ const MessagePublishModal: React.FC = ({ command.commandText, ); if (!res?.success) { - void message.error(`发送失败: ${res?.message || '未知错误'}`); + void message.error(t('message_publish_modal.error.send_failed_detail', { + detail: res?.message || t('message_publish_modal.error.unknown_error'), + })); return; } @@ -113,19 +116,22 @@ const MessagePublishModal: React.FC = ({ commandText: command.commandText, }); } catch (error: any) { - void message.error(`发送失败: ${error?.message || String(error)}`); + void message.error(t('message_publish_modal.error.send_failed_detail', { detail: error?.message || String(error) })); } finally { setSubmitting(false); } }; + const modalTitle = connection?.name + ? t('message_publish_modal.title_with_connection', { connectionName: connection.name }) + : t('message_publish_modal.title'); return ( { void handleSubmit(); }} - okText="发送" + okText={t('message_publish_modal.action.send')} confirmLoading={submitting} width={720} destroyOnHidden @@ -153,21 +159,21 @@ const MessagePublishModal: React.FC = ({ {presentation.showExchange && ( - + )} {presentation.showRoutingKey && ( - + )} @@ -175,7 +181,7 @@ const MessagePublishModal: React.FC = ({ @@ -205,11 +211,16 @@ const MessagePublishModal: React.FC = ({ {presentation.showDelayLevel && ( - )} @@ -221,7 +232,7 @@ const MessagePublishModal: React.FC = ({ -