diff --git a/frontend/src/components/DataGrid.ddl.test.tsx b/frontend/src/components/DataGrid.ddl.test.tsx index 8bfcc69..5b80dca 100644 --- a/frontend/src/components/DataGrid.ddl.test.tsx +++ b/frontend/src/components/DataGrid.ddl.test.tsx @@ -467,9 +467,12 @@ describe('DataGrid commit change set', () => { rowKeyToString, normalizeCommitCellValue: normalizeValue, shouldCommitColumn: commitColumnGuard, - }); + rowLocatorMessages: { + noSafeLocator: () => 'No safe row locator is available for this result set.', + }, + } as any); - expect(result).toEqual({ ok: false, error: '当前结果没有可用的安全行定位方式,无法提交修改。' }); + expect(result).toEqual({ ok: false, error: 'No safe row locator is available for this result set.' }); }); it('rejects delete rows when unique locator value is null', () => { @@ -490,7 +493,38 @@ describe('DataGrid commit change set', () => { shouldCommitColumn: commitColumnGuard, }); - expect(result).toEqual({ ok: false, error: '定位列 EMAIL 的值为空,无法安全提交修改。' }); + expect(result).toEqual({ ok: false, error: 'Locator column EMAIL is empty, so changes cannot be submitted safely.' }); + }); + + it('keeps DataGrid safe locator fallback messages out of source Chinese literals', () => { + const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + const rowLocatorSource = readFileSync(new URL('../utils/rowLocator.ts', import.meta.url), 'utf8'); + + expect(`${dataGridSource}\n${rowLocatorSource}`).not.toMatch(/当前结果没有可用的安全行定位方式|定位列 .* 的值为空,无法安全提交修改/); + expect(dataGridSource).toContain('data_grid.message.no_safe_locator'); + expect(dataGridSource).toContain('data_grid.message.locator_column_value_empty'); + }); + + it('keeps DataGrid column quick-find warning messages localized', () => { + const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + expect(dataGridSource).not.toMatch(/未找到字段列|当前未渲染,无法定位/); + expect(dataGridSource).toContain('data_grid.message.column_quick_find_not_found'); + expect(dataGridSource).toContain('data_grid.message.column_quick_find_not_rendered'); + }); + + it('keeps DataGrid datetime picker now footer localized', () => { + const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + expect(dataGridSource).not.toContain('>此刻'); + expect(dataGridSource).toContain('data_grid.datetime_picker.now'); + }); + + it('keeps DataGrid AI insight prompt wrapper localized', () => { + const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8'); + + expect(dataGridSource).not.toMatch(/请帮我分析以下查询结果数据|请分析数据特征|业务上的洞察/); + expect(dataGridSource).toContain('data_grid.ai_insight.prompt'); }); it('marks the active virtual editing row so shouldCellUpdate can reopen inline editors', () => { diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 132b432..ae7c717 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -106,6 +106,7 @@ import { resolveWritableColumnName, resolveRowLocatorValues, type EditRowLocator, + type RowLocatorMessages, } from '../utils/rowLocator'; import { getColumnDefinitionComment, @@ -883,6 +884,8 @@ const EditableCell: React.FC = React.memo(({ const scrollLockRef = useRef<{ el: HTMLElement; handler: (e: WheelEvent) => void } | null>(null); const form = useContext(EditableContext); const cellContextMenuContext = useContext(CellContextMenuContext); + const i18nLanguage = useDataGridI18nLanguage(); + const dateTimePickerNowLabel = t('data_grid.datetime_picker.now', undefined, i18nLanguage); /** DatePicker 面板打开时锁定表格滚动,关闭时恢复 */ const lockTableScroll = useCallback((lock: boolean) => { @@ -1000,7 +1003,7 @@ const EditableCell: React.FC = React.memo(({ const fieldName = getCellFieldName(record, dataIndex); setCellFieldValue(form, fieldName, dayjs()); }} - >此刻 + >{dateTimePickerNowLabel} )} onOk={(value) => setTimeout(() => { void save((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} onOpenChange={(open) => { @@ -1384,6 +1387,7 @@ export const buildDataGridCommitChangeSet = ({ rowKeyToString, normalizeCommitCellValue, shouldCommitColumn, + rowLocatorMessages, }: { addedRows: any[]; modifiedRows: Record; @@ -1394,9 +1398,10 @@ export const buildDataGridCommitChangeSet = ({ rowKeyToString: (key: any) => string; normalizeCommitCellValue: NormalizeCommitCellValue; shouldCommitColumn: (columnName: string) => boolean; + rowLocatorMessages?: RowLocatorMessages; }): { ok: true; changes: DataGridCommitChangeSet } | { ok: false; error: string } => { if (!editLocator || editLocator.readOnly || editLocator.strategy === 'none') { - return { ok: false, error: editLocator?.reason || '当前结果没有可用的安全行定位方式,无法提交修改。' }; + return { ok: false, error: editLocator?.reason || rowLocatorMessages?.noSafeLocator?.() || 'No safe row locator is available for this result set.' }; } const normalizeValues = (values: Record, mode: 'insert' | 'update') => { @@ -1433,7 +1438,7 @@ export const buildDataGridCommitChangeSet = ({ for (const keyStr of deletedRowKeys) { const originalRow = originalRowsByKey.get(keyStr); if (!originalRow) continue; - const locatorValues = resolveRowLocatorValues(editLocator, originalRow); + const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; deletes.push(locatorValues.values); } @@ -1443,7 +1448,7 @@ export const buildDataGridCommitChangeSet = ({ const originalRow = originalRowsByKey.get(keyStr); if (!originalRow) continue; - const locatorValues = resolveRowLocatorValues(editLocator, originalRow); + const locatorValues = resolveRowLocatorValues(editLocator, originalRow, rowLocatorMessages); if (!locatorValues.ok) return { ok: false, error: locatorValues.error }; const hasRowKey = Object.prototype.hasOwnProperty.call(newRow as any, GONAVI_ROW_KEY); @@ -1527,6 +1532,10 @@ const DataGrid: React.FC = ({ }, [language] ); + const rowLocatorMessages = useMemo(() => ({ + noSafeLocator: () => translateDataGrid('data_grid.message.no_safe_locator'), + emptyLocatorValue: (column: string) => translateDataGrid('data_grid.message.locator_column_value_empty', { column }), + }), [translateDataGrid]); const isMacLike = useMemo(() => isMacLikePlatform(), []); const isV2Ui = appearance?.uiVersion === 'v2'; @@ -5028,7 +5037,7 @@ const DataGrid: React.FC = ({ onClick={() => { setCellFieldValue(form, getCellFieldName(record, dataIndex), dayjs()); }} - >此刻 + >{translateDataGrid('data_grid.datetime_picker.now')} )} onOk={(value) => setTimeout(() => { void saveVirtualInlineEditor((value as dayjs.Dayjs | null | undefined) ?? undefined); }, 0)} onOpenChange={(open) => { @@ -5218,6 +5227,7 @@ const DataGrid: React.FC = ({ rowKeyToString: rowKeyStr, normalizeCommitCellValue, shouldCommitColumn, + rowLocatorMessages, }); if (!changeSetResult.ok) { void message.error(changeSetResult.error @@ -5260,7 +5270,7 @@ const DataGrid: React.FC = ({ } }, [addedRows, modifiedRows, deletedRowKeys, data, effectiveEditLocator, visibleColumnNames, rowKeyStr, normalizeCommitCellValue, shouldCommitColumn, - connectionId, tableName, connections, translateDataGrid]); + connectionId, tableName, connections, rowLocatorMessages, translateDataGrid]); const handleCommit = async () => { if (!connectionId || !tableName) return; @@ -5276,6 +5286,7 @@ const DataGrid: React.FC = ({ rowKeyToString: rowKeyStr, normalizeCommitCellValue, shouldCommitColumn, + rowLocatorMessages, }); if (!changeSetResult.ok) { void message.error(changeSetResult.error @@ -6777,7 +6788,7 @@ const DataGrid: React.FC = ({ const targetColumnName = resolveColumnQuickFindTarget(effectiveQuery); if (!targetColumnName) { if (effectiveQuery.trim()) { - void message.warning(`未找到字段列:${effectiveQuery.trim()}`); + void message.warning(translateDataGrid('data_grid.message.column_quick_find_not_found', { query: effectiveQuery.trim() })); } return; } @@ -6788,10 +6799,10 @@ const DataGrid: React.FC = ({ if (tryFocus()) return; requestAnimationFrame(() => { if (tryFocus()) return; - void message.warning(`字段列“${targetColumnName}”当前未渲染,无法定位`); + void message.warning(translateDataGrid('data_grid.message.column_quick_find_not_rendered', { column: targetColumnName })); }); }); - }, [columnQuickFindText, focusColumnQuickFindTarget, resolveColumnQuickFindTarget]); + }, [columnQuickFindText, focusColumnQuickFindTarget, resolveColumnQuickFindTarget, translateDataGrid]); // 外部水平滚动条的 wheel 处理(通过原生事件绑定,确保 preventDefault 生效) useEffect(() => { @@ -7369,14 +7380,17 @@ const DataGrid: React.FC = ({ const handleRequestAiInsight = useCallback(() => { const sampleData = mergedDisplayData.slice(0, 10); - const prompt = `请帮我分析以下查询结果数据(取前 ${sampleData.length} 条示例):\n\`\`\`json\n${JSON.stringify(sampleData, null, 2)}\n\`\`\`\n\n请分析数据特征、发现规律,或者给出一些业务上的洞察。`; + const prompt = translateDataGrid('data_grid.ai_insight.prompt', { + count: sampleData.length, + json: JSON.stringify(sampleData, null, 2), + }); const store = useStore.getState(); const wasClosed = !store.aiPanelVisible; if (wasClosed) store.setAIPanelVisible(true); setTimeout(() => { window.dispatchEvent(new CustomEvent('gonavi:ai:inject-prompt', { detail: { prompt } })); }, wasClosed ? 350 : 0); - }, [mergedDisplayData]); + }, [mergedDisplayData, translateDataGrid]); const handleToggleTotalCount = useCallback(() => { if (!onRequestTotalCount) return; diff --git a/frontend/src/components/DataViewer.primary-key.test.tsx b/frontend/src/components/DataViewer.primary-key.test.tsx index be0b3fd..69240a5 100644 --- a/frontend/src/components/DataViewer.primary-key.test.tsx +++ b/frontend/src/components/DataViewer.primary-key.test.tsx @@ -22,6 +22,7 @@ const storeState = vi.hoisted(() => ({ }, }, ], + languagePreference: 'zh-CN', addSqlLog: vi.fn(), })); @@ -107,6 +108,21 @@ describe('DataViewer safe editing locator', () => { beforeEach(() => { vi.clearAllMocks(); dataGridState.latestProps = null; + storeState.connections = [ + { + id: 'conn-1', + name: 'oracle', + config: { + type: 'oracle', + host: '127.0.0.1', + port: 1521, + user: 'scott', + password: '', + database: 'ORCLPDB1', + }, + }, + ]; + storeState.languagePreference = 'zh-CN'; storeState.connections[0].config.type = 'oracle'; storeState.connections[0].config.database = 'ORCLPDB1'; backendApp.DBQuery.mockResolvedValue({ @@ -117,6 +133,37 @@ describe('DataViewer safe editing locator', () => { backendApp.DBGetIndexes.mockResolvedValue({ success: true, data: [] }); }); + it('localizes the missing connection message through DataViewer catalog keys', async () => { + storeState.connections = []; + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + await Promise.resolve(); + }); + await flushPromises(); + + expect(messageApi.error).toHaveBeenCalledWith('未找到连接'); + renderer!.unmount(); + }); + + it('keeps DataViewer message wrappers and SQL log phase labels keyed', () => { + const source = readFileSync(new URL('./DataViewer.tsx', import.meta.url), 'utf8'); + + expect(source).not.toMatch(/当前结果集尚未就绪|统计失败|统计总数失败|统计结果解析失败|Mongo 筛选条件无效|解析失败|主查询|复杂类型降级重试|重试\(32MB sort_buffer\)|重试\(128MB sort_buffer\)|已自动提升排序缓冲并重试成功|查询失败|查询超过连接超时时间|DuckDB 查询超过连接超时时间|超时|MongoDB 结果集中缺少 _id|加载索引失败|无法加载主键\/唯一索引元数据|无法加载唯一索引元数据|保持只读|当前结果没有可用的安全行定位方式/); + expect(source).toContain('data_viewer.message.connection_not_found'); + expect(source).toContain('data_viewer.message.result_not_ready'); + expect(source).toContain('data_viewer.message.query_failed'); + expect(source).toContain('data_viewer.message.query_timeout'); + expect(source).toContain('data_viewer.message.duckdb_query_timeout'); + expect(source).toContain('data_viewer.read_only.reason.mongo_id_missing'); + expect(source).toContain('data_viewer.read_only.reason.no_safe_locator'); + expect(source).toContain('data_viewer.read_only.warning.table'); + expect(source).toContain('data_viewer.read_only.warning.collection'); + expect(source).toContain('data_viewer.sql_log.phase.main_query'); + expect(source).toContain('data_viewer.sql_log.phase.sort_buffer_retry'); + }); + it('enables table preview editing after primary keys are loaded', async () => { backendApp.DBGetColumns.mockResolvedValue({ success: true, @@ -204,6 +251,7 @@ describe('DataViewer safe editing locator', () => { }); it('keeps MongoDB results read-only when _id is missing', async () => { + storeState.languagePreference = 'en-US'; storeState.connections[0].config.type = 'mongodb'; storeState.connections[0].config.database = 'app'; backendApp.DBQuery.mockResolvedValue({ @@ -218,10 +266,10 @@ describe('DataViewer safe editing locator', () => { expect(dataGridState.latestProps?.editLocator).toMatchObject({ strategy: 'none', readOnly: true, - reason: 'MongoDB 结果集中缺少 _id,无法安全提交修改。', + reason: 'MongoDB result set is missing _id, so changes cannot be submitted safely.', }); expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith('集合 app.users 保持只读:MongoDB 结果集中缺少 _id,无法安全提交修改。'); + expect(messageApi.warning).toHaveBeenCalledWith('Collection app.users remains read-only: MongoDB result set is missing _id, so changes cannot be submitted safely.'); renderer.unmount(); }); @@ -324,6 +372,7 @@ describe('DataViewer safe editing locator', () => { }); it('shows an actionable message for DuckDB timeout interruption errors', async () => { + storeState.languagePreference = 'en-US'; storeState.connections[0].config.type = 'duckdb'; storeState.connections[0].config.database = 'main'; backendApp.DBGetColumns.mockResolvedValue({ @@ -339,12 +388,13 @@ describe('DataViewer safe editing locator', () => { const renderer = await renderAndReload(createTab({ id: 'tab-duckdb-timeout', dbName: 'main', tableName: 'events', title: 'events' })); - expect(messageApi.error).toHaveBeenCalledWith('DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。'); + expect(messageApi.error).toHaveBeenCalledWith('DuckDB query exceeded the connection timeout and was interrupted. Increase the connection timeout, or reduce the sort/filter scope and retry.'); expect(storeState.addSqlLog.mock.calls.some((call: any[]) => String(call[0]?.message || '').includes('context deadline exceeded'))).toBe(true); renderer.unmount(); }); it('keeps non-Oracle table preview read-only when no safe locator exists', async () => { + storeState.languagePreference = 'en-US'; storeState.connections[0].config.type = 'mysql'; storeState.connections[0].config.database = 'main'; backendApp.DBGetColumns.mockResolvedValue({ @@ -358,10 +408,10 @@ describe('DataViewer safe editing locator', () => { expect(dataGridState.latestProps?.editLocator).toMatchObject({ strategy: 'none', readOnly: true, - reason: '未检测到主键或可用唯一索引,无法安全提交修改。', + reason: 'No primary key or usable unique index was found, so changes cannot be submitted safely.', }); expect(dataGridState.latestProps?.readOnly).toBe(true); - expect(messageApi.warning).toHaveBeenCalledWith('表 main.users 保持只读:未检测到主键或可用唯一索引,无法安全提交修改。'); + expect(messageApi.warning).toHaveBeenCalledWith('Table main.users remains read-only: No primary key or usable unique index was found, so changes cannot be submitted safely.'); renderer.unmount(); }); }); diff --git a/frontend/src/components/DataViewer.tsx b/frontend/src/components/DataViewer.tsx index bcae350..381cb49 100644 --- a/frontend/src/components/DataViewer.tsx +++ b/frontend/src/components/DataViewer.tsx @@ -10,6 +10,7 @@ import { buildOracleApproximateTotalSql, parseApproximateTableCountRow, resolveA import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities'; import { resolveDataViewerAutoFetchAction } from '../utils/dataViewerAutoFetch'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; +import { resolveLanguage, t as translate, type I18nParams } from '../i18n'; import { buildEffectiveFilterConditions, normalizeQuickWhereCondition, @@ -38,6 +39,8 @@ type ViewerPaginationState = { totalCountCancelled: boolean; }; +type DataViewerTranslator = (key: string, params?: I18nParams) => string; + const JS_MAX_SAFE_INTEGER_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); const isIntegerText = (text: string): boolean => /^[+-]?\d+$/.test(text); @@ -103,6 +106,47 @@ const buildDataViewerReadOnlyLocator = (reason: string): EditRowLocator => ({ reason, }); +const READ_ONLY_REASON_NO_SAFE_LOCATOR = '\u672a\u68c0\u6d4b\u5230\u4e3b\u952e\u6216\u53ef\u7528\u552f\u4e00\u7d22\u5f15\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002'; +const READ_ONLY_REASON_ORACLE_ROWID_MISSING = '\u672a\u68c0\u6d4b\u5230\u4e3b\u952e\u6216\u53ef\u7528\u552f\u4e00\u7d22\u5f15\uff0c\u4e14\u7ed3\u679c\u4e2d\u7f3a\u5c11 Oracle ROWID\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002'; +const READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX = '\u7ed3\u679c\u96c6\u4e2d\u7f3a\u5c11\u4e3b\u952e\u5217 '; +const READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX = '\uff0c\u65e0\u6cd5\u5b89\u5168\u63d0\u4ea4\u4fee\u6539\u3002'; + +const localizeDataViewerReadOnlyReason = (reason: string | undefined, tr: DataViewerTranslator): string => { + const text = String(reason || '').trim(); + if (!text) return tr('data_viewer.read_only.reason.no_safe_locator'); + if (text === READ_ONLY_REASON_NO_SAFE_LOCATOR) { + return tr('data_viewer.read_only.reason.no_safe_locator'); + } + if (text === READ_ONLY_REASON_ORACLE_ROWID_MISSING) { + return tr('data_viewer.read_only.reason.oracle_rowid_missing'); + } + if (text.startsWith(READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX) && text.endsWith(READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX)) { + const columns = text.slice(READ_ONLY_REASON_PRIMARY_KEY_MISSING_PREFIX.length, -READ_ONLY_REASON_SAFE_SUBMIT_SUFFIX.length); + return tr('data_viewer.read_only.reason.primary_key_column_missing', { columns }); + } + return text; +}; + +const localizeDataViewerReadOnlyLocator = (locator: EditRowLocator, tr: DataViewerTranslator): EditRowLocator => { + if (!locator.readOnly) return locator; + return { ...locator, reason: localizeDataViewerReadOnlyReason(locator.reason, tr) }; +}; + +const warnDataViewerReadOnly = ( + kind: 'table' | 'collection', + target: string, + reason: string | undefined, + tr: DataViewerTranslator, +) => { + const key = kind === 'table' + ? 'data_viewer.read_only.warning.table' + : 'data_viewer.read_only.warning.collection'; + message.warning(tr(key, { + target, + reason: localizeDataViewerReadOnlyReason(reason, tr), + })); +}; + const formatDataViewerTableName = (dbName: string, tableName: string): string => ( dbName ? `${dbName}.${tableName}` : tableName ); @@ -116,13 +160,13 @@ const getTableColumnNames = (columns: ColumnDefinition[] | undefined): string[] const MONGODB_ID_COLUMN = '_id'; const MONGODB_ID_LOCATOR_COLUMN = '__gonavi_mongodb_id_locator__'; -const buildMongoDataViewerEditLocator = (resultColumns: string[]): EditRowLocator => { +const buildMongoDataViewerEditLocator = (resultColumns: string[], tr: DataViewerTranslator): EditRowLocator => { const columns = (resultColumns || []) .map((column) => String(column || '').trim()) .filter(Boolean); const idColumn = columns.find((column) => column.toLowerCase() === MONGODB_ID_COLUMN); if (!idColumn) { - return buildDataViewerReadOnlyLocator('MongoDB 结果集中缺少 _id,无法安全提交修改。'); + return buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.mongo_id_missing')); } const locatorValueColumn = columns.find((column) => column === MONGODB_ID_LOCATOR_COLUMN) || idColumn; @@ -210,16 +254,16 @@ const isDuckDBComplexColumnType = (columnType?: string): boolean => { return raw.includes('map') || raw.includes('struct') || raw.includes('union') || raw.includes('array') || raw.includes('list'); }; -const formatDataViewerQueryError = (dbType: string, messageText: unknown): string => { - const rawMessage = String(messageText || '查询失败').trim() || '查询失败'; +const formatDataViewerQueryError = (dbType: string, messageText: unknown, tr: DataViewerTranslator): string => { + const rawMessage = String(messageText || tr('data_viewer.message.query_failed')).trim() || tr('data_viewer.message.query_failed'); const lower = rawMessage.toLowerCase(); - const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('超时'); + const isTimeout = lower.includes('context deadline exceeded') || lower.includes('deadline exceeded') || lower.includes('timeout') || lower.includes('timed out') || lower.includes('\u8d85\u65f6'); const isDuckDBInterrupted = String(dbType || '').trim().toLowerCase() === 'duckdb' && (lower.includes('interrupt error') || lower.includes('interrupted')); if (isTimeout || isDuckDBInterrupted) { if (String(dbType || '').trim().toLowerCase() === 'duckdb') { - return 'DuckDB 查询超过连接超时时间,已中断。请调大连接超时时间,或减少排序/筛选范围后重试。'; + return tr('data_viewer.message.duckdb_query_timeout'); } - return '查询超过连接超时时间,已中断。请调大连接超时时间,或减少查询范围后重试。'; + return tr('data_viewer.message.query_timeout'); } return rawMessage; }; @@ -286,6 +330,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const connections = useStore(state => state.connections); const addSqlLog = useStore(state => state.addSqlLog); const appearance = useStore(state => state.appearance); + const languagePreference = useStore(state => state.languagePreference); + const language = resolveLanguage(languagePreference); + const tr = useCallback((key: string, params?: I18nParams) => translate(key, params, language), [language]); const isV2Ui = appearance?.uiVersion === 'v2'; const fetchSeqRef = useRef(0); const countSeqRef = useRef(0); @@ -433,7 +480,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const countKey = latestCountKeyRef.current; if (!config || !countSql || !countKey) { - message.warning('当前结果集尚未就绪,请先执行一次加载'); + message.warning(tr('data_viewer.message.result_not_ready')); return; } @@ -452,7 +499,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ sql: countSql, status: resCount?.success ? 'success' : 'error', duration: countDuration, - message: resCount?.success ? '' : String(resCount?.message || '统计失败'), + message: resCount?.success ? '' : String(resCount?.message || tr('data_viewer.message.total_count_failed')), dbName }); @@ -461,7 +508,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ if (!resCount?.success) { setPagination(prev => ({ ...prev, totalCountLoading: false })); - message.error(String(resCount?.message || '统计总数失败')); + message.error(String(resCount?.message || tr('data_viewer.message.total_count_failed'))); return; } if (!Array.isArray(resCount.data) || resCount.data.length === 0) { @@ -472,7 +519,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const total = parseTotalFromCountRow(resCount.data[0]); if (total === null) { setPagination(prev => ({ ...prev, totalCountLoading: false })); - message.error('统计结果解析失败'); + message.error(tr('data_viewer.message.total_count_parse_failed')); return; } @@ -489,9 +536,9 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ if (manualCountSeqRef.current !== countSeq) return; if (manualCountKeyRef.current !== countKey) return; setPagination(prev => ({ ...prev, totalCountLoading: false })); - message.error(`统计总数失败: ${String(e?.message || e)}`); + message.error(tr('data_viewer.message.total_count_failed_detail', { detail: String(e?.message || e) })); } - }, [addSqlLog]); + }, [addSqlLog, tr]); const handleCancelManualTotalCount = useCallback(() => { manualCountSeqRef.current++; @@ -503,7 +550,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ setLoading(true); const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { - message.error("Connection not found"); + message.error(tr('data_viewer.message.connection_not_found')); if (fetchSeqRef.current === seq) setLoading(false); return; } @@ -537,7 +584,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ try { mongoFilter = buildMongoFilter(effectiveFilterConditions); } catch (e: any) { - message.error(`Mongo 筛选条件无效:${String(e?.message || e || '解析失败')}`); + const detail = String(e?.message || e || tr('data_viewer.message.mongo_filter_parse_failed')); + message.error(tr('data_viewer.message.mongo_filter_invalid_detail', { detail })); if (fetchSeqRef.current === seq) setLoading(false); return; } @@ -561,19 +609,19 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const [resCols, resIndexes] = await Promise.all([ DBGetColumns(buildRpcConnectionConfig(config) as any, dbName, tableName), DBGetIndexes(buildRpcConnectionConfig(config) as any, dbName, tableName) - .catch((error: any) => ({ success: false, message: String(error?.message || error || '加载索引失败'), data: [] })), + .catch((error: any) => ({ success: false, message: String(error?.message || error || 'Failed to load indexes'), data: [] })), ]); if (fetchSeqRef.current !== seq) return; if (pkSeqRef.current !== locatorSeq) return; if (pkKeyRef.current !== locatorKey) return; if (!resCols?.success || !Array.isArray(resCols.data)) { - const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。'); + const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable')); pkColumnsForQuery = []; editLocatorForQuery = nextLocator; setPkColumns([]); setEditLocator(nextLocator); - message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`); + warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr); } else { const columnDefs = resCols.data as ColumnDefinition[]; const primaryKeys = columnDefs @@ -587,16 +635,16 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const locatorColumns = isOracleLikeDialect(dbType) ? [...resultColumns, ORACLE_ROWID_LOCATOR_COLUMN] : resultColumns; - let nextLocator = resolveEditRowLocator({ + let nextLocator = localizeDataViewerReadOnlyLocator(resolveEditRowLocator({ dbType, resultColumns: locatorColumns, primaryKeys, indexes, allowOracleRowID: true, - }); + }), tr); if (nextLocator.readOnly && primaryKeys.length === 0 && !resIndexes?.success && !isOracleLikeDialect(dbType)) { - nextLocator = buildDataViewerReadOnlyLocator('无法加载唯一索引元数据,无法安全提交修改。'); + nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.index_metadata_unavailable')); } pkColumnsForQuery = primaryKeys; @@ -604,19 +652,19 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ setPkColumns(primaryKeys); setEditLocator(nextLocator); if (nextLocator.readOnly) { - message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`); + warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr); } } } catch { if (fetchSeqRef.current !== seq) return; if (pkSeqRef.current !== locatorSeq) return; if (pkKeyRef.current !== locatorKey) return; - const nextLocator = buildDataViewerReadOnlyLocator('无法加载主键/唯一索引元数据,无法安全提交修改。'); + const nextLocator = buildDataViewerReadOnlyLocator(tr('data_viewer.read_only.reason.metadata_unavailable')); pkColumnsForQuery = []; editLocatorForQuery = nextLocator; setPkColumns([]); setEditLocator(nextLocator); - message.warning(`表 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason}`); + warnDataViewerReadOnly('table', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr); } } } @@ -660,8 +708,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ } else { const baseSql = buildDataViewerBaseSelectSQL(dbType, tableName, whereSQL, editLocatorForQuery); sql = `${baseSql}${orderBySQL}`; - // ClickHouse 深分页在超大 OFFSET 下容易超时。对于总数已知且存在 ORDER BY 的场景, - // 当“尾部偏移”小于“头部偏移”时,改为反向 ORDER BY + 小 OFFSET,并在前端翻转结果。 + // ClickHouse deep pagination with very large OFFSET can be slow. When the tail offset is smaller, + // query in reverse ORDER BY with a smaller OFFSET, then reverse rows in the frontend. if (isClickHouse && totalKnown && offset > 0 && reverseOrderSQL) { const pageRowCount = Math.max(0, Math.min(size, totalRows - offset)); if (pageRowCount > 0) { @@ -715,7 +763,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const hasSort = hasExplicitSort(sortInfo); const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || '')); - let resData = await executeDataQuery(sql, '主查询'); + let resData = await executeDataQuery(sql, tr('data_viewer.sql_log.phase.main_query')); if (!resData.success && dbTypeLower === 'duckdb' && isDuckDBUnsupportedTypeError(String(resData.message || ''))) { const cacheKey = `${tab.connectionId}|${dbName}|${tableName}`; @@ -748,7 +796,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ let fallbackSql = `SELECT ${safeSelect} FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; fallbackSql = buildPaginatedSelectSQL(dbType, fallbackSql, buildOrderBySQL(dbType, sortInfo, resolveDataViewerOrderFallbackColumns(editLocatorForQuery, pkColumnsForQuery)), size + 1, offset); executedSql = fallbackSql; - resData = await executeDataQuery(fallbackSql, '复杂类型降级重试'); + resData = await executeDataQuery(fallbackSql, tr('data_viewer.sql_log.phase.complex_type_fallback_retry')); } } @@ -756,17 +804,17 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024); if (retrySql32MB !== sql) { executedSql = retrySql32MB; - resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)'); + resData = await executeDataQuery(retrySql32MB, tr('data_viewer.sql_log.phase.sort_buffer_retry', { size: '32MB' })); } if (!resData.success && isSortMemoryErr(resData.message)) { const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024); if (retrySql128MB !== executedSql) { executedSql = retrySql128MB; - resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)'); + resData = await executeDataQuery(retrySql128MB, tr('data_viewer.sql_log.phase.sort_buffer_retry', { size: '128MB' })); } } if (resData.success) { - message.warning('已自动提升排序缓冲并重试成功。'); + message.warning(tr('data_viewer.message.sort_buffer_retry_succeeded')); } } @@ -788,13 +836,13 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ } if (fetchSeqRef.current !== seq) return; if (isMongoDB && !forceReadOnly && tableName) { - const nextLocator = buildMongoDataViewerEditLocator(fieldNames); + const nextLocator = buildMongoDataViewerEditLocator(fieldNames, tr); pkColumnsForQuery = nextLocator.readOnly ? [] : [MONGODB_ID_COLUMN]; editLocatorForQuery = nextLocator; setPkColumns(pkColumnsForQuery); setEditLocator(nextLocator); if (nextLocator.readOnly && resultData.length > 0) { - message.warning(`集合 ${formatDataViewerTableName(dbName, tableName)} 保持只读:${nextLocator.reason || '当前结果没有可用的安全行定位方式,无法提交修改。'}`); + warnDataViewerReadOnly('collection', formatDataViewerTableName(dbName, tableName), nextLocator.reason, tr); } } setColumnNames(fieldNames); @@ -880,8 +928,8 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ countKeyRef.current = countKey; const countSeq = ++countSeqRef.current; const countStart = Date.now(); - // 大表 COUNT(*) 可能非常慢,且在部分运行时环境下会影响后续操作响应; - // DuckDB 大文件场景下该统计会显著拖慢翻页,已禁用后台 COUNT。 + // Large-table COUNT(*) can be slow and may delay later operations in some runtimes. + // DuckDB large-file scenarios disable background COUNT because it can slow pagination significantly. const countConfig = buildRpcConnectionConfig(config, { timeout: 5 }); DBQuery(countConfig, dbName, countSql) @@ -920,7 +968,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ .catch(() => { if (countSeqRef.current !== countSeq) return; if (countKeyRef.current !== countKey) return; - // 统计失败不影响主流程,不弹窗;可在日志里查看。 + // Count failures do not block the main flow; details stay in the SQL log. }); } } @@ -1009,11 +1057,11 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ } } } else { - message.error(formatDataViewerQueryError(dbTypeLower, resData.message)); + message.error(formatDataViewerQueryError(dbTypeLower, resData.message, tr)); } } catch (e: any) { if (fetchSeqRef.current !== seq) return; - message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e)); + message.error(formatDataViewerQueryError(dbTypeLower, e?.message || e, tr)); addSqlLog({ id: `log-${Date.now()}-error`, timestamp: Date.now(), @@ -1025,7 +1073,7 @@ const DataViewer: React.FC<{ tab: TabData; isActive?: boolean }> = React.memo(({ }); } if (fetchSeqRef.current === seq) setLoading(false); - }, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages]); + }, [connections, tab, sortInfo, filterConditions, quickWhereCondition, pkColumns, editLocator, forceReadOnly, pagination.total, pagination.totalKnown, pagination.totalApprox, pagination.approximateTotal, preferManualTotalCount, supportsApproximateTableCount, supportsApproximateTotalPages, tr]); // 依赖定位列:在无手动排序时可回退到安全定位列稳定排序。 // 定位信息只会在表上下文变化后重新加载,避免循环查询。 diff --git a/frontend/src/components/DefinitionViewer.i18n.test.ts b/frontend/src/components/DefinitionViewer.i18n.test.ts new file mode 100644 index 0000000..91377d4 --- /dev/null +++ b/frontend/src/components/DefinitionViewer.i18n.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +describe('DefinitionViewer i18n', () => { + it('keeps DefinitionViewer shell and validation copy localized', () => { + const source = readFileSync(new URL('./DefinitionViewer.tsx', import.meta.url), 'utf8'); + + expect(source).not.toMatch(/setError\('未找到数据库连接'|setError\('视图名称为空'|setError\('事件名称为空'|setError\('函数\/存储过程名称为空'/); + expect(source).not.toMatch(/setError\(result\.message \|\| '查询定义失败'|setError\('查询定义失败: '/); + expect(source).not.toMatch(/数据库:|>类型:/); + expect(source).not.toMatch(/objectLabel = tab\.viewKind === 'materialized' \? '物化视图' : '视图'|objectLabel = '事件'|objectLabel = '函数\/存储过程'/); + + expect(source).toContain('definition_viewer.error.connection_not_found'); + expect(source).toContain('definition_viewer.error.view_name_empty'); + expect(source).toContain('definition_viewer.error.event_name_empty'); + expect(source).toContain('definition_viewer.error.routine_name_empty'); + expect(source).toContain('definition_viewer.error.query_failed'); + expect(source).toContain('definition_viewer.error.query_failed_detail'); + expect(source).toContain('definition_viewer.loading.view_definition'); + expect(source).toContain('definition_viewer.field.database'); + expect(source).toContain('definition_viewer.field.type'); + }); + + it('keeps DefinitionViewer editor fallback comments localized', () => { + const source = readFileSync(new URL('./DefinitionViewer.tsx', import.meta.url), 'utf8'); + + expect(source).not.toMatch(/暂不支持该数据库类型的视图定义查看|SQLite 不支持函数\/存储过程定义管理|暂不支持该数据库类型的函数\/存储过程定义查看|暂不支持该数据库类型的事件定义查看/); + expect(source).not.toMatch(/未找到视图定义|未找到函数\/存储过程定义|未找到事件定义|暂不支持该对象定义查看/); + expect(source).not.toMatch(/当前数据源未返回可执行定义文本|当前数据源未返回完整 CREATE EVENT 语句|名称: |类型: /); + expect(source).not.toMatch(/当前 Sphinx 实例|已执行多套兼容查询|返回失败信息: |unknown error/); + + expect(source).toContain('definition_viewer.editor.unsupported_view_definition'); + expect(source).toContain('definition_viewer.editor.unsupported_sqlite_routine_definition'); + expect(source).toContain('definition_viewer.editor.unsupported_routine_definition'); + expect(source).toContain('definition_viewer.editor.event_definition_not_found'); + expect(source).toContain('definition_viewer.editor.sphinx.failed_message_unknown'); + }); +}); diff --git a/frontend/src/components/DefinitionViewer.tsx b/frontend/src/components/DefinitionViewer.tsx index e063ee6..1fff34b 100644 --- a/frontend/src/components/DefinitionViewer.tsx +++ b/frontend/src/components/DefinitionViewer.tsx @@ -6,6 +6,7 @@ import { useStore } from '../store'; import { DBQuery } from '../../wailsjs/go/app/App'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; +import { useI18n } from '../i18n/provider'; interface DefinitionViewerProps { tab: TabData; @@ -36,6 +37,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { const connections = useStore(state => state.connections); const theme = useStore(state => state.theme); const darkMode = theme === 'dark'; + const { t } = useI18n(); const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''"); @@ -170,7 +172,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { return [`SELECT view_definition FROM information_schema.views WHERE table_schema = '${escapeSQLLiteral(schemaRef)}' AND table_name = '${safeName}' LIMIT 1`]; } default: - return [`-- 暂不支持该数据库类型的视图定义查看`]; + return [`-- ${t('definition_viewer.editor.unsupported_view_definition')}`]; } }; @@ -219,9 +221,9 @@ const DefinitionViewer: React.FC = ({ tab }) => { ]; } case 'sqlite': - return [`-- SQLite 不支持函数/存储过程定义管理`]; + return [`-- ${t('definition_viewer.editor.unsupported_sqlite_routine_definition')}`]; default: - return [`-- 暂不支持该数据库类型的函数/存储过程定义查看`]; + return [`-- ${t('definition_viewer.editor.unsupported_routine_definition')}`]; } }; @@ -242,7 +244,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { : '', ].filter(Boolean); default: - return [`-- 暂不支持该数据库类型的事件定义查看`]; + return [`-- ${t('definition_viewer.editor.unsupported_event_definition')}`]; } }; @@ -305,7 +307,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { }; const extractViewDefinition = (dialect: string, data: any[]): string => { - if (!data || data.length === 0) return '-- 未找到视图定义'; + if (!data || data.length === 0) return `-- ${t('definition_viewer.editor.view_definition_not_found')}`; const row = data[0]; switch (dialect) { @@ -335,7 +337,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { }; const extractRoutineDefinition = (dialect: string, data: any[]): string => { - if (!data || data.length === 0) return '-- 未找到函数/存储过程定义'; + if (!data || data.length === 0) return `-- ${t('definition_viewer.editor.routine_definition_not_found')}`; switch (dialect) { case 'mysql': @@ -356,7 +358,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { const routineName = String(row.Name || row.name || '').trim(); if (routineName) { const routineType = String(row.Type || row.type || row.ROUTINE_TYPE || row.routine_type || 'FUNCTION').trim().toUpperCase(); - return `-- 当前数据源未返回可执行定义文本,已返回元数据\n-- 名称: ${routineName}\n-- 类型: ${routineType}\n${JSON.stringify(row, null, 2)}`; + return `-- ${t('definition_viewer.editor.metadata_fallback.header')}\n-- ${t('definition_viewer.editor.metadata_fallback.name_label')}: ${routineName}\n-- ${t('definition_viewer.editor.metadata_fallback.type_label')}: ${routineType}\n${JSON.stringify(row, null, 2)}`; } return JSON.stringify(row, null, 2); } @@ -388,7 +390,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { }; const extractEventDefinition = (dialect: string, data: any[]): string => { - if (!data || data.length === 0) return '-- 未找到事件定义'; + if (!data || data.length === 0) return `-- ${t('definition_viewer.editor.event_definition_not_found')}`; switch (dialect) { case 'mysql': { @@ -400,7 +402,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { const definition = row.event_definition || row.EVENT_DEFINITION; const eventName = row.event_name || row.EVENT_NAME || row.Name || row.name; if (definition && eventName) { - return `-- 当前数据源未返回完整 CREATE EVENT 语句,已返回事件定义片段\n-- 名称: ${eventName}\n${String(definition)}`; + return `-- ${t('definition_viewer.editor.event_fragment_fallback.header')}\n-- ${t('definition_viewer.editor.metadata_fallback.name_label')}: ${eventName}\n${String(definition)}`; } return JSON.stringify(row, null, 2); } @@ -418,7 +420,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { - setError('未找到数据库连接'); + setError(t('definition_viewer.error.connection_not_found')); setLoading(false); return; } @@ -434,38 +436,40 @@ const DefinitionViewer: React.FC = ({ tab }) => { if (tab.type === 'view-def') { const viewName = tab.viewName || ''; if (!viewName) { - setError('视图名称为空'); + setError(t('definition_viewer.error.view_name_empty')); setLoading(false); return; } queries = buildShowViewQueries(dialect, viewName, dbName, tab.viewKind); extractFn = extractViewDefinition; - objectLabel = tab.viewKind === 'materialized' ? '物化视图' : '视图'; + objectLabel = tab.viewKind === 'materialized' + ? t('definition_viewer.object.materialized_view') + : t('definition_viewer.object.view'); } else if (tab.type === 'event-def') { const eventName = tab.eventName || ''; if (!eventName) { - setError('事件名称为空'); + setError(t('definition_viewer.error.event_name_empty')); setLoading(false); return; } queries = buildShowEventQueries(dialect, eventName, dbName); extractFn = extractEventDefinition; - objectLabel = '事件'; + objectLabel = t('definition_viewer.object.event'); } else { const routineName = tab.routineName || ''; const routineType = tab.routineType || 'FUNCTION'; if (!routineName) { - setError('函数/存储过程名称为空'); + setError(t('definition_viewer.error.routine_name_empty')); setLoading(false); return; } queries = buildShowRoutineQueries(dialect, routineName, routineType, dbName); extractFn = extractRoutineDefinition; - objectLabel = '函数/存储过程'; + objectLabel = t('definition_viewer.object.routine'); } if (!queries.length || String(queries[0] || '').startsWith('--')) { - setDefinition(String(queries[0] || '-- 暂不支持该对象定义查看')); + setDefinition(String(queries[0] || `-- ${t('definition_viewer.editor.unsupported_object_definition')}`)); setLoading(false); return; } @@ -491,39 +495,45 @@ const DefinitionViewer: React.FC = ({ tab }) => { if (result.success) { if (sphinxLike) { const version = await getVersionHint(config, dbName); - const versionText = version ? `(版本: ${version})` : ''; - setDefinition(`-- 当前 Sphinx 实例${versionText}未返回${objectLabel}定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。`); + const versionText = version ? t('definition_viewer.editor.sphinx.version_suffix', { version }) : ''; + setDefinition(`-- ${t('definition_viewer.editor.sphinx.empty_result', { version: versionText, object: objectLabel })}\n-- ${t('definition_viewer.editor.sphinx.compat_queries_hint')}`); return; } - setDefinition(`-- 未找到${objectLabel}定义`); + setDefinition(`-- ${t('definition_viewer.editor.object_definition_not_found', { object: objectLabel })}`); } else if (sphinxLike) { const version = await getVersionHint(config, dbName); - const versionText = version ? `(版本: ${version})` : ''; - setDefinition(`-- 当前 Sphinx 实例${versionText}不支持${objectLabel}定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}`); + const versionText = version ? t('definition_viewer.editor.sphinx.version_suffix', { version }) : ''; + const failedMessage = result.message + ? `${t('definition_viewer.editor.sphinx.failed_message_label')}: ${result.message}` + : t('definition_viewer.editor.sphinx.failed_message_unknown'); + setDefinition(`-- ${t('definition_viewer.editor.sphinx.unsupported_query', { version: versionText, object: objectLabel })}\n-- ${failedMessage}`); } else { - setError(result.message || '查询定义失败'); + setError(result.message || t('definition_viewer.error.query_failed')); } } catch (e: any) { - setError('查询定义失败: ' + (e?.message || String(e))); + setError(t('definition_viewer.error.query_failed_detail', { detail: e?.message || String(e) })); } finally { setLoading(false); } }; loadDefinition(); - }, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections]); + }, [tab.connectionId, tab.dbName, tab.viewName, tab.viewKind, tab.eventName, tab.routineName, tab.routineType, tab.type, connections, t]); const objectLabel = tab.type === 'view-def' - ? (tab.viewKind === 'materialized' ? '物化视图' : '视图') - : (tab.type === 'event-def' ? '事件' : '函数/存储过程'); + ? (tab.viewKind === 'materialized' ? t('definition_viewer.object.materialized_view') : t('definition_viewer.object.view')) + : (tab.type === 'event-def' ? t('definition_viewer.object.event') : t('definition_viewer.object.routine')); const objectName = tab.type === 'view-def' ? tab.viewName : (tab.type === 'event-def' ? tab.eventName : tab.routineName); + const loadingTip = tab.type === 'view-def' + ? t('definition_viewer.loading.view_definition') + : (tab.type === 'event-def' ? t('definition_viewer.loading.event_definition') : t('definition_viewer.loading.routine_definition')); if (loading) { return (
- +
); } @@ -531,7 +541,7 @@ const DefinitionViewer: React.FC = ({ tab }) => { if (error) { return (
- +
); } @@ -540,8 +550,8 @@ const DefinitionViewer: React.FC = ({ tab }) => {
{objectLabel}: {objectName} - {tab.dbName && 数据库: {tab.dbName}} - {tab.routineType && 类型: {tab.routineType}} + {tab.dbName && {t('definition_viewer.field.database')}: {tab.dbName}} + {tab.routineType && {t('definition_viewer.field.type')}: {tab.routineType}}
({ + dbQuery: vi.fn(), + dbGetTables: vi.fn(), + dbGetAllColumns: vi.fn(), + message: { + warning: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, + storeState: { + connections: [ + { + id: "conn-1", + config: { + type: "mysql", + host: "localhost", + port: 3306, + user: "root", + password: "", + database: "app", + }, + }, + ], + theme: "light", + }, +})); + +vi.mock("../store", () => ({ + useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), +})); + +vi.mock("../i18n/runtime", () => ({ + applyDayjsLocale: vi.fn(), + syncLanguageRuntime: vi.fn(), +})); + +vi.mock("../../wailsjs/go/app/App", () => ({ + DBQuery: mocks.dbQuery, + DBGetTables: mocks.dbGetTables, + DBGetAllColumns: mocks.dbGetAllColumns, +})); + +vi.mock("antd", async () => { + const React = await import("react"); + return { + Modal: ({ + children, + open, + title, + }: { + children?: React.ReactNode; + open?: boolean; + title?: React.ReactNode; + }) => (open ? React.createElement("section", null, title, children) : null), + Input: ({ + placeholder, + value, + onChange, + }: { + placeholder?: string; + value?: string; + onChange?: (event: { target: { value: string } }) => void; + }) => + React.createElement("input", { + placeholder, + value, + onChange: (event: any) => onChange?.({ target: { value: event.target.value } }), + }), + Button: ({ + children, + disabled, + icon, + onClick, + }: { + children?: React.ReactNode; + disabled?: boolean; + icon?: React.ReactNode; + onClick?: () => void; + }) => React.createElement("button", { disabled, onClick }, icon, children), + Select: ({ options }: { options?: Array<{ label: React.ReactNode; value: string }> }) => + React.createElement( + "div", + null, + options?.map((option) => React.createElement("span", { key: option.value }, option.label)), + ), + Table: ({ columns, dataSource }: { columns?: any[]; dataSource?: any[] }) => + React.createElement( + "div", + null, + columns?.map((column) => React.createElement("span", { key: column.key || column.dataIndex }, column.title)), + dataSource?.map((row, index) => + React.createElement( + "div", + { key: index }, + Object.values(row).map((value, valueIndex) => + React.createElement("span", { key: valueIndex }, String(value)), + ), + ), + ), + ), + Progress: ({ percent }: { percent: number }) => React.createElement("div", null, `${percent}%`), + Space: ({ children }: { children?: React.ReactNode }) => React.createElement("div", null, children), + Tag: ({ children }: { children?: React.ReactNode }) => React.createElement("span", null, children), + Tooltip: ({ children, title }: { children?: React.ReactNode; title?: React.ReactNode }) => + React.createElement("span", { title }, children), + Empty: ({ description }: { description?: React.ReactNode }) => React.createElement("div", null, description), + message: mocks.message, + }; +}); + +vi.mock("@ant-design/icons", async () => { + const React = await import("react"); + const Icon = () => React.createElement("span", null); + return { + SearchOutlined: Icon, + StopOutlined: Icon, + EyeOutlined: Icon, + DatabaseOutlined: Icon, + }; +}); + +const textContent = (node: any): string => { + if (node === null || node === undefined) return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map((item) => textContent(item)).join(""); + return textContent(node.children || []); +}; + +const renderFindModal = () => { + let renderer!: ReactTestRenderer; + act(() => { + renderer = create( + undefined}> + + , + ); + }); + return renderer; +}; + +describe("FindInDatabaseModal i18n", () => { + beforeEach(() => { + mocks.dbQuery.mockReset(); + mocks.dbGetTables.mockReset(); + mocks.dbGetAllColumns.mockReset(); + mocks.message.warning.mockClear(); + mocks.message.error.mockClear(); + mocks.message.info.mockClear(); + }); + + it("renders search chrome in the active language while preserving raw database name", () => { + const renderer = renderFindModal(); + const renderedText = textContent(renderer.toJSON()); + const input = renderer.root.findByType("input"); + + expect(renderedText).toContain("Search in database - app_db"); + expect(input.props.placeholder).toBe("Enter the string to search for..."); + expect(renderedText).toContain("Contains"); + expect(renderedText).toContain("Exact match"); + expect(renderedText).toContain("Search"); + }); + + it("localizes controlled error wrappers while preserving backend detail", async () => { + mocks.dbGetTables.mockResolvedValue({ success: false, message: "driver raw detail" }); + const renderer = renderFindModal(); + + const input = renderer.root.findByType("input"); + await act(async () => { + input.props.onChange({ target: { value: "alice" } }); + }); + + const searchButton = renderer.root.findAllByType("button").find((button) => textContent(button).includes("Search")); + expect(searchButton).toBeTruthy(); + + await act(async () => { + searchButton?.props.onClick(); + await Promise.resolve(); + }); + + expect(mocks.message.error).toHaveBeenCalledWith("Failed to get table list: driver raw detail"); + }); + + it("does not keep migrated Chinese UI literals in FindInDatabaseModal source", () => { + const source = readFileSync(new URL("./FindInDatabaseModal.tsx", import.meta.url), "utf8"); + + expect(source).not.toContain("请输入搜索关键字"); + expect(source).not.toContain("未找到连接配置"); + expect(source).not.toContain("获取表列表失败"); + expect(source).not.toContain("当前数据库没有表"); + expect(source).not.toContain("未找到匹配的数据"); + expect(source).not.toContain("搜索出错"); + expect(source).not.toContain("在数据库中搜索"); + expect(source).not.toContain("输入要搜索的字符串"); + expect(source).not.toContain("精确匹配"); + expect(source).not.toContain("匹配行详情"); + }); +}); diff --git a/frontend/src/components/FindInDatabaseModal.tsx b/frontend/src/components/FindInDatabaseModal.tsx index a81ea41..21c76cb 100644 --- a/frontend/src/components/FindInDatabaseModal.tsx +++ b/frontend/src/components/FindInDatabaseModal.tsx @@ -7,6 +7,7 @@ import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { isMacLikePlatform } from '../utils/appearance'; +import { useI18n } from '../i18n/provider'; interface FindInDatabaseModalProps { open: boolean; @@ -23,24 +24,24 @@ interface SearchResultItem { columns: string[]; } -/** 判断数据库列类型是否为文本类型(只搜索文本字段) */ +/** Returns whether a database column type is searchable as text. */ const isTextColumnType = (colType: string): boolean => { const t = (colType || '').toLowerCase().trim(); - // 显式排除非文本类型 + // Explicitly skip non-text types before falling back to searchable. if (/^(int|bigint|smallint|tinyint|mediumint|float|double|decimal|numeric|real|money|smallmoney|bit|boolean|bool)/.test(t)) return false; if (/^(date|time|datetime|timestamp|year|interval)/.test(t)) return false; if (/^(blob|binary|varbinary|image|bytea|raw|long raw)/.test(t)) return false; if (/^(geometry|geography|point|line|polygon|spatial)/.test(t)) return false; if (/^(json|jsonb|xml|uuid|uniqueidentifier)/.test(t)) return false; if (/^(serial|bigserial|smallserial|autoincrement|identity)/.test(t)) return false; - // 文本类型正匹配 + // Positive text type matches. if (/^(varchar|char|nvarchar|nchar|text|ntext|tinytext|mediumtext|longtext|string|clob|nclob|character)/.test(t)) return true; if (t === 'sysname' || t === 'sql_variant') return true; - // 未知类型默认尝试搜索 + // Unknown types are attempted by default. return true; }; -/** 根据 dbType 构建限制返回行数的 SELECT SQL */ +/** Builds a SELECT statement with a dialect-specific row limit. */ const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): string => { const normalizedType = (dbType || '').toLowerCase(); switch (normalizedType) { @@ -58,6 +59,7 @@ const buildLimitedSelectSQL = (dbType: string, baseSql: string, limit: number): const MAX_MATCH_ROWS_PER_TABLE = 100; const FindInDatabaseModal: React.FC = ({ open, onClose, connectionId, dbName }) => { + const { t } = useI18n(); const [keyword, setKeyword] = useState(''); const [matchMode, setMatchMode] = useState<'contains' | 'exact'>('contains'); const [searching, setSearching] = useState(false); @@ -93,12 +95,12 @@ const FindInDatabaseModal: React.FC = ({ open, onClose const handleSearch = useCallback(async () => { const searchKeyword = keyword.trim(); if (!searchKeyword) { - message.warning('请输入搜索关键字'); + message.warning(t('find_in_database.message.keyword_required')); return; } const config = buildConfig(); if (!config) { - message.error('未找到连接配置'); + message.error(t('find_in_database.message.connection_config_not_found')); return; } @@ -108,10 +110,9 @@ const FindInDatabaseModal: React.FC = ({ open, onClose cancelledRef.current = false; try { - // 1. 获取所有表 const tablesRes = await DBGetTables(buildRpcConnectionConfig(config) as any, dbName); if (!tablesRes.success) { - message.error('获取表列表失败: ' + tablesRes.message); + message.error(t('find_in_database.message.get_tables_failed', { detail: tablesRes.message })); setSearching(false); return; } @@ -119,18 +120,16 @@ const FindInDatabaseModal: React.FC = ({ open, onClose const tableNames = tableRows.map((row: any) => Object.values(row)[0] as string).filter(Boolean); if (tableNames.length === 0) { - message.info('当前数据库没有表'); + message.info(t('find_in_database.message.no_tables')); setSearching(false); return; } setProgress({ current: 0, total: tableNames.length, tableName: '' }); - // 2. 获取所有列信息(返回 any[],含 tableName/name/type 字段) const allColsRes = await DBGetAllColumns(buildRpcConnectionConfig(config) as any, dbName); const allColumns: any[] = (allColsRes?.success && Array.isArray(allColsRes.data)) ? allColsRes.data : []; - // 按表名分组 const columnsByTable: Record> = {}; allColumns.forEach((col: any) => { const tbl = col.tableName || ''; @@ -141,20 +140,17 @@ const FindInDatabaseModal: React.FC = ({ open, onClose const searchResults: SearchResultItem[] = []; const escapedKeyword = escapeLiteral(searchKeyword); - // 3. 逐表搜索 for (let i = 0; i < tableNames.length; i++) { if (cancelledRef.current) break; const tableName = tableNames[i]; setProgress({ current: i + 1, total: tableNames.length, tableName }); - // 获取该表的文本列 const tableCols = columnsByTable[tableName] || []; const textCols = tableCols.filter(c => isTextColumnType(c.type)); if (textCols.length === 0) continue; - // 构建 WHERE 子句 const castType = (dbType === 'sqlserver' || dbType === 'mssql') ? 'NVARCHAR(MAX)' : 'CHAR'; const whereConditions = textCols.map(c => { const quotedCol = quoteIdentPart(dbType, c.name); @@ -171,7 +167,6 @@ const FindInDatabaseModal: React.FC = ({ open, onClose try { const res = await DBQuery(buildRpcConnectionConfig(config) as any, dbName, sql); if (res.success && Array.isArray(res.data) && res.data.length > 0) { - // 检查哪些列实际匹配了 const matchedCols = new Set(); const lowerKeyword = searchKeyword.toLowerCase(); res.data.forEach((row: any) => { @@ -199,22 +194,22 @@ const FindInDatabaseModal: React.FC = ({ open, onClose } } } catch { - // 单表查询失败不中断整体搜索 + // Per-table query failures should not stop the whole search. } } if (!cancelledRef.current) { setResults([...searchResults]); if (searchResults.length === 0) { - message.info('未找到匹配的数据'); + message.info(t('find_in_database.message.no_matches')); } } } catch (e: any) { - message.error('搜索出错: ' + (e?.message || String(e))); + message.error(t('find_in_database.message.search_failed', { detail: e?.message || String(e) })); } finally { setSearching(false); } - }, [keyword, matchMode, dbName, dbType, buildConfig]); + }, [keyword, matchMode, dbName, dbType, buildConfig, t]); const handleCancel = useCallback(() => { cancelledRef.current = true; @@ -228,10 +223,9 @@ const FindInDatabaseModal: React.FC = ({ open, onClose onClose(); }, [onClose]); - // 汇总表的列定义 const summaryColumns = useMemo(() => [ { - title: '表名', + title: t('find_in_database.column.table_name'), dataIndex: 'tableName', key: 'tableName', width: 220, @@ -243,7 +237,7 @@ const FindInDatabaseModal: React.FC = ({ open, onClose ), }, { - title: '匹配列', + title: t('find_in_database.column.matched_columns'), dataIndex: 'matchedColumns', key: 'matchedColumns', render: (cols: string[]) => ( @@ -255,7 +249,7 @@ const FindInDatabaseModal: React.FC = ({ open, onClose ), }, { - title: '命中行数', + title: t('find_in_database.column.match_count'), dataIndex: 'matchCount', key: 'matchCount', width: 100, @@ -267,12 +261,12 @@ const FindInDatabaseModal: React.FC = ({ open, onClose ), }, { - title: '操作', + title: t('find_in_database.column.action'), key: 'action', width: 80, align: 'center' as const, render: (_: any, record: SearchResultItem) => ( - + ) : ( )}
- {/* 进度条 */} {searching && (
= ({ open, onClose strokeColor={wt.iconColor} /> - 正在搜索 {progress.tableName}... ({progress.current}/{progress.total}) + {t('find_in_database.progress.searching_table', { + table: progress.tableName, + current: progress.current, + total: progress.total, + })}
)} - {/* 结果汇总表 */} {results.length > 0 && (
- 找到 {results.length} 个表包含匹配数据 - {searching && '(搜索进行中...)'} + {t('find_in_database.summary.found_tables', { count: results.length })} + {searching && t('find_in_database.summary.searching')}
= ({ open, onClose )} - {/* 详情展开 */} {expandedResult && (
= ({ open, onClose }}> - {expandedResult.tableName} — 匹配行详情 + {t('find_in_database.detail.title', { table: expandedResult.tableName })} - {expandedResult.rows.length} 行 + {t('find_in_database.detail.row_count', { count: expandedResult.rows.length })}
({ ...row, __rowIdx: i }))} @@ -453,9 +446,8 @@ const FindInDatabaseModal: React.FC = ({ open, onClose )} - {/* 无结果且搜索完成 */} {!searching && results.length === 0 && progress.total > 0 && ( - + )} diff --git a/frontend/src/components/ImportPreviewModal.test.tsx b/frontend/src/components/ImportPreviewModal.test.tsx new file mode 100644 index 0000000..ec943f9 --- /dev/null +++ b/frontend/src/components/ImportPreviewModal.test.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { readFileSync } from "node:fs"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; + +import { I18nProvider } from "../i18n/provider"; +import ImportPreviewModal from "./ImportPreviewModal"; + +const mocks = vi.hoisted(() => ({ + previewImportFile: vi.fn(), + importDataWithProgress: vi.fn(), + eventsOn: vi.fn(() => vi.fn()), + eventsOff: vi.fn(), + storeState: { + connections: [ + { + id: "conn-1", + config: { + type: "mysql", + host: "localhost", + port: 3306, + user: "root", + password: "", + database: "app", + }, + }, + ], + }, +})); + +vi.mock("../store", () => ({ + useStore: (selector: (state: typeof mocks.storeState) => unknown) => selector(mocks.storeState), +})); + +vi.mock("../i18n/runtime", () => ({ + applyDayjsLocale: vi.fn(), + syncLanguageRuntime: vi.fn(), +})); + +vi.mock("../../wailsjs/go/app/App", () => ({ + PreviewImportFile: mocks.previewImportFile, + ImportDataWithProgress: mocks.importDataWithProgress, +})); + +vi.mock("../../wailsjs/runtime/runtime", () => ({ + EventsOn: mocks.eventsOn, + EventsOff: mocks.eventsOff, +})); + +vi.mock("antd", async () => { + const React = await import("react"); + const Modal = ({ + children, + footer, + open, + title, + }: { + children?: React.ReactNode; + footer?: React.ReactNode; + open?: boolean; + title?: React.ReactNode; + }) => (open ? React.createElement("section", null, title, children, footer) : null); + const Table = ({ columns, dataSource }: { columns?: any[]; dataSource?: any[] }) => + React.createElement( + "div", + null, + columns?.map((column) => React.createElement("span", { key: column.key || column.dataIndex }, column.title)), + dataSource?.map((row, index) => + React.createElement( + "div", + { key: index }, + Object.values(row).map((value, valueIndex) => + React.createElement("span", { key: valueIndex }, String(value)), + ), + ), + ), + ); + return { + Modal, + Table, + Alert: ({ message, description }: { message?: React.ReactNode; description?: React.ReactNode }) => + React.createElement("div", null, message, description), + Progress: ({ percent }: { percent: number }) => React.createElement("div", null, `${percent}%`), + Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => + React.createElement("button", { onClick }, children), + Space: ({ children }: { children?: React.ReactNode }) => React.createElement("div", null, children), + }; +}); + +vi.mock("@ant-design/icons", async () => { + const React = await import("react"); + const Icon = () => React.createElement("span", null); + return { + CheckCircleOutlined: Icon, + CloseCircleOutlined: Icon, + }; +}); + +const textContent = (node: any): string => { + if (node === null || node === undefined) return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map((item) => textContent(item)).join(""); + return textContent(node.children || []); +}; + +const renderImportPreview = async () => { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + undefined}> + + , + ); + await Promise.resolve(); + await Promise.resolve(); + }); + return renderer; +}; + +describe("ImportPreviewModal i18n", () => { + beforeEach(() => { + mocks.previewImportFile.mockResolvedValue({ + success: true, + data: { + columns: ["id", "user_name"], + totalRows: 12, + previewRows: [{ id: 1, user_name: "alice" }], + }, + }); + mocks.importDataWithProgress.mockReset(); + mocks.eventsOn.mockClear(); + mocks.eventsOff.mockClear(); + }); + + it("renders preview chrome in the active language while preserving raw column names", async () => { + const renderer = await renderImportPreview(); + const renderedText = textContent(renderer.toJSON()); + + expect(renderedText).toContain("Import data preview"); + expect(renderedText).toContain("12 rows and 2 fields"); + expect(renderedText).toContain("The first 5 rows are shown below. Start the import after confirming the data."); + expect(renderedText).toContain("Field list:"); + expect(renderedText).toContain("Data preview (first 5 rows):"); + expect(renderedText).toContain("Cancel"); + expect(renderedText).toContain("Start import"); + expect(renderedText).toContain("id"); + expect(renderedText).toContain("user_name"); + }); + + it("does not keep migrated Chinese UI literals in ImportPreviewModal source", () => { + const source = readFileSync(new URL("./ImportPreviewModal.tsx", import.meta.url), "utf8"); + + expect(source).not.toContain("导入数据预览"); + expect(source).not.toContain("开始导入"); + expect(source).not.toContain("加载预览数据..."); + expect(source).not.toContain("字段列表:"); + expect(source).not.toContain("数据预览(前 5 行):"); + expect(source).not.toContain("正在导入数据..."); + expect(source).not.toContain("错误日志:"); + }); +}); diff --git a/frontend/src/components/ImportPreviewModal.tsx b/frontend/src/components/ImportPreviewModal.tsx index b3f4cc7..a4524e2 100644 --- a/frontend/src/components/ImportPreviewModal.tsx +++ b/frontend/src/components/ImportPreviewModal.tsx @@ -4,6 +4,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { PreviewImportFile, ImportDataWithProgress } from '../../wailsjs/go/app/App'; import { EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'; import { useStore } from '../store'; +import { useI18n } from '../i18n/provider'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; interface ImportPreviewModalProps { visible: boolean; @@ -37,6 +38,7 @@ const ImportPreviewModal: React.FC = ({ onClose, onSuccess }) => { + const { t } = useI18n(); const connections = useStore(state => state.connections); const [loading, setLoading] = useState(true); const [previewData, setPreviewData] = useState(null); @@ -74,10 +76,10 @@ const ImportPreviewModal: React.FC = ({ previewRows: res.data.previewRows || [] }); } else { - setError(res.message || '预览失败'); + setError(res.message || t('import_preview.error.preview_failed')); } } catch (e: any) { - setError('预览失败: ' + e.message); + setError(t('import_preview.error.preview_failed_detail', { detail: String(e?.message || e) })); } finally { setLoading(false); } @@ -93,7 +95,7 @@ const ImportPreviewModal: React.FC = ({ try { const conn = connections.find(c => c.id === connectionId); if (!conn) { - setError('连接配置未找到'); + setError(t('import_preview.error.connection_config_not_found')); setImporting(false); return; } @@ -115,10 +117,10 @@ const ImportPreviewModal: React.FC = ({ onSuccess(); } } else { - setError(res.message || '导入失败'); + setError(res.message || t('import_preview.error.import_failed')); } } catch (e: any) { - setError('导入失败: ' + e.message); + setError(t('import_preview.error.import_failed_detail', { detail: String(e?.message || e) })); } finally { setImporting(false); } @@ -136,24 +138,24 @@ const ImportPreviewModal: React.FC = ({ return ( - + ) : importing ? null : ( - + ) @@ -161,22 +163,22 @@ const ImportPreviewModal: React.FC = ({ > {error && } - {loading &&
加载预览数据...
} + {loading &&
{t('import_preview.status.loading_preview')}
} {!loading && previewData && !importing && !importResult && ( <> -
字段列表:
+
{t('import_preview.preview.field_list')}
{previewData.columns.join(', ')}
-
数据预览(前 5 行):
+
{t('import_preview.preview.table_title')}
= ({ {importing && progress && (
- 正在导入数据... + {t('import_preview.status.importing')}
- 已处理 {progress.current} / {progress.total} 行 + {t('import_preview.progress.processed_rows', { current: progress.current, total: progress.total })} - 成功 {progress.success} + {t('import_preview.progress.success_count', { count: progress.success })} {progress.errors > 0 && ( - 失败 {progress.errors} + {t('import_preview.progress.error_count', { count: progress.errors })} )}
@@ -212,11 +214,11 @@ const ImportPreviewModal: React.FC = ({
-
成功导入 {importResult.success} 行
- {importResult.failed > 0 &&
失败 {importResult.failed} 行
} +
{t('import_preview.result.success_rows', { count: importResult.success })}
+ {importResult.failed > 0 &&
{t('import_preview.result.failed_rows', { count: importResult.failed })}
}
} showIcon @@ -224,7 +226,7 @@ const ImportPreviewModal: React.FC = ({ /> {importResult.errorLogs && importResult.errorLogs.length > 0 && ( <> -
错误日志:
+
{t('import_preview.result.error_logs')}
({ useStore: (selector: (state: any) => any) => selector({ - connections: [ - { - id: "conn-jvm-1", - name: "orders-jvm", - config: { - host: "localhost", - port: 10990, - jvm: { - preferredMode: "endpoint", - readOnly: false, - }, - }, - }, - ], - theme: "light", + connections: mockState.connections, + theme: mockState.theme, }), })); +const tab = { + id: "tab-jvm-audit", + type: "jvm-audit", + title: "[orders-jvm] JVM 审计", + connectionId: "conn-jvm-1", + providerMode: "endpoint", +} as any; + +const renderWithI18n = (node: React.ReactNode, preference: LanguagePreference = "en-US") => ( + + {node} + +); + +beforeEach(() => { + mockState.connections = [ + { + id: "conn-jvm-1", + name: "orders-jvm", + config: { + host: "localhost", + port: 10990, + jvm: { + preferredMode: "endpoint", + readOnly: false, + }, + }, + }, + ]; + mockState.theme = "light"; + backendApp.JVMListAuditRecords.mockReset(); + vi.stubGlobal("window", { + go: { + app: { + App: backendApp, + }, + }, + }); +}); + describe("JVMAuditViewer", () => { - it("renders a unified JVM workspace audit shell", () => { + it("renders a localized en-US JVM workspace audit shell", () => { const markup = renderToStaticMarkup( - , + renderWithI18n(), ); expect(markup).toContain('data-jvm-workspace-shell="true"'); expect(markup).toContain('data-jvm-workspace-hero="true"'); - expect(markup).toContain("JVM 变更审计"); - expect(markup).toContain("审计记录"); - expect(markup).toContain("最近 50 条"); + [ + t("jvm_audit.eyebrow", undefined, "en-US"), + t("jvm_audit.title", undefined, "en-US"), + t("jvm_audit.card.records", undefined, "en-US"), + t("jvm_audit.description.current_range", { limit: 50 }, "en-US"), + t("jvm_audit.action.refresh", undefined, "en-US"), + t("jvm_audit.option.last_records", { limit: 50 }, "en-US"), + t("jvm_audit.column.time", undefined, "en-US"), + t("jvm_audit.column.mode", undefined, "en-US"), + t("jvm_audit.column.action", undefined, "en-US"), + t("jvm_audit.column.resource", undefined, "en-US"), + t("jvm_audit.column.reason", undefined, "en-US"), + t("jvm_audit.column.source", undefined, "en-US"), + t("jvm_audit.column.result", undefined, "en-US"), + t("jvm_audit.empty.no_records", undefined, "en-US"), + ].forEach((snippet) => { + expect(markup).toContain(snippet); + }); + + [ + "JVM 变更审计", + "审计记录", + "当前范围", + "最近 50 条", + "刷新", + "时间", + "模式", + "动作", + "资源", + "原因", + "来源", + "结果", + "暂无审计记录", + ].forEach((snippet) => { + expect(markup).not.toContain(snippet); + }); + }); + + it("renders the missing connection empty state in en-US", () => { + mockState.connections = []; + + const markup = renderToStaticMarkup( + renderWithI18n(), + ); + + expect(markup).toContain( + t("jvm_audit.error.connection_missing", undefined, "en-US"), + ); + expect(markup).not.toContain("连接不存在或已被删除"); + }); + + it("wires non-SSR audit wrappers and source tags through existing i18n keys", () => { + [ + "AI 辅助", + "手工", + "JVMListAuditRecords 后端方法不可用", + "读取 JVM 审计记录失败", + "当前无法加载审计记录", + ].forEach((snippet) => { + expect(source).not.toContain(snippet); + }); + + [ + "jvm_audit.source.ai_plan", + "jvm_audit.source.manual", + "jvm_audit.error.backend_unavailable", + "jvm_audit.error.load_failed", + "jvm_audit.empty.load_failed", + ].forEach((key) => { + expect(source).toContain(key); + }); + }); + + it("passes the active language to action and result presentation helpers", () => { + expect(source).toContain("language } = useI18n()"); + expect(source).toContain("formatTimestamp(value, language)"); + expect(source).toContain("formatJVMActionDisplayText(value, language)"); + expect(source).toContain("formatJVMAuditResultLabel(value, language)"); + expect(source).not.toContain('toLocaleString("zh-CN"'); }); }); diff --git a/frontend/src/components/JVMAuditViewer.tsx b/frontend/src/components/JVMAuditViewer.tsx index ca573ec..e86b990 100644 --- a/frontend/src/components/JVMAuditViewer.tsx +++ b/frontend/src/components/JVMAuditViewer.tsx @@ -13,6 +13,7 @@ import { import type { ColumnsType } from "antd/es/table"; import { ReloadOutlined } from "@ant-design/icons"; +import { useI18n } from "../i18n/provider"; import { useStore } from "../store"; import type { JVMAuditRecord, TabData } from "../types"; import { @@ -63,7 +64,7 @@ const filterAuditRecordsByMode = ( ); }; -const formatTimestamp = (timestamp: number): string => { +const formatTimestamp = (timestamp: number, language?: string): string => { if (!timestamp) { return "-"; } @@ -72,10 +73,11 @@ const formatTimestamp = (timestamp: number): string => { if (Number.isNaN(date.getTime())) { return String(timestamp); } - return date.toLocaleString("zh-CN", { hour12: false }); + return date.toLocaleString(language || "zh-CN", { hour12: false }); }; const JVMAuditViewer: React.FC = ({ tab }) => { + const { t, language } = useI18n(); const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId), ); @@ -86,17 +88,25 @@ const JVMAuditViewer: React.FC = ({ tab }) => { const [records, setRecords] = useState([]); const [error, setError] = useState(""); + const formatLoadFailedError = (detail?: unknown): string => { + const normalizedDetail = String(detail || "").trim(); + return t("jvm_audit.error.load_failed", { + separator: normalizedDetail ? ": " : "", + detail: normalizedDetail, + }); + }; + const columns = useMemo>( () => [ { - title: "时间", + title: t("jvm_audit.column.time"), dataIndex: "timestamp", key: "timestamp", width: 180, - render: (value: number) => formatTimestamp(value), + render: (value: number) => formatTimestamp(value, language), }, { - title: "模式", + title: t("jvm_audit.column.mode"), dataIndex: "providerMode", key: "providerMode", width: 120, @@ -105,28 +115,29 @@ const JVMAuditViewer: React.FC = ({ tab }) => { ), }, { - title: "动作", + title: t("jvm_audit.column.action"), dataIndex: "action", key: "action", width: 160, - render: (value: string) => formatJVMActionDisplayText(value) || "-", + render: (value: string) => + formatJVMActionDisplayText(value, language) || "-", }, { - title: "资源", + title: t("jvm_audit.column.resource"), dataIndex: "resourceId", key: "resourceId", ellipsis: true, render: (value: string) => value || "-", }, { - title: "原因", + title: t("jvm_audit.column.reason"), dataIndex: "reason", key: "reason", ellipsis: true, render: (value: string) => value || "-", }, { - title: "来源", + title: t("jvm_audit.column.source"), dataIndex: "source", key: "source", width: 120, @@ -135,31 +146,31 @@ const JVMAuditViewer: React.FC = ({ tab }) => { .trim() .toLowerCase(); if (normalized === "ai-plan") { - return AI 辅助; + return {t("jvm_audit.source.ai_plan")}; } - return 手工; + return {t("jvm_audit.source.manual")}; }, }, { - title: "结果", + title: t("jvm_audit.column.result"), dataIndex: "result", key: "result", width: 140, render: (value: string) => ( - {formatJVMAuditResultLabel(value)} + {formatJVMAuditResultLabel(value, language)} ), }, ], - [tab.providerMode], + [language, tab.providerMode, t], ); const loadRecords = async () => { if (!connection) { setLoading(false); setRecords([]); - setError("连接不存在或已被删除"); + setError(t("jvm_audit.error.connection_missing")); return; } @@ -167,7 +178,7 @@ const JVMAuditViewer: React.FC = ({ tab }) => { if (typeof backendApp?.JVMListAuditRecords !== "function") { setLoading(false); setRecords([]); - setError("JVMListAuditRecords 后端方法不可用"); + setError(t("jvm_audit.error.backend_unavailable")); return; } @@ -177,7 +188,7 @@ const JVMAuditViewer: React.FC = ({ tab }) => { const result = await backendApp.JVMListAuditRecords(connection.id, limit); if (result?.success === false) { setRecords([]); - setError(String(result?.message || "读取 JVM 审计记录失败")); + setError(formatLoadFailedError(result?.message)); return; } setRecords( @@ -188,7 +199,11 @@ const JVMAuditViewer: React.FC = ({ tab }) => { ); } catch (err: any) { setRecords([]); - setError(err?.message || "读取 JVM 审计记录失败"); + setError( + formatLoadFailedError( + err?.message || (typeof err === "string" ? err : ""), + ), + ); } finally { setLoading(false); } @@ -196,11 +211,14 @@ const JVMAuditViewer: React.FC = ({ tab }) => { useEffect(() => { void loadRecords(); - }, [connection, limit, tab.connectionId]); + }, [connection, limit, tab.connectionId, tab.providerMode, t]); if (!connection) { return ( - + ); } @@ -212,13 +230,16 @@ const JVMAuditViewer: React.FC = ({ tab }) => { {connection.name} · {connection.id} - · 当前范围:最近 {limit} 条 + + {" · "} + {t("jvm_audit.description.current_range", { limit })} + } badges={} @@ -229,7 +250,7 @@ const JVMAuditViewer: React.FC = ({ tab }) => { icon={} onClick={() => void loadRecords()} > - 刷新 + {t("jvm_audit.action.refresh")} setDraft(tab.id, { reason: event.target.value }) } /> - 用于审计记录和 AI 上下文理解,不会作为 Arthas 命令发送到目标 JVM。 + {t("jvm_diagnostic.command_input.reason_help")}
, "命令模板")} + title={renderCardTitle( + , + t("jvm_diagnostic.command_templates.title"), + )} variant="borderless" style={cardStyle} styles={compactCardStyles} @@ -1042,8 +1132,8 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { , - "实时输出", - "按后端事件流追加显示", + t("jvm_diagnostic.output.title"), + t("jvm_diagnostic.output.description"), )} variant="borderless" style={cardStyle} @@ -1058,8 +1148,8 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { , - "会话与能力", - "当前通道、权限与快捷维护", + t("jvm_diagnostic.session_capability.title"), + t("jvm_diagnostic.session_capability.description"), )} variant="borderless" style={cardStyle} @@ -1077,11 +1167,15 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { > - {hasSession ? "会话已建立" : "未建会话"} + {hasSession + ? t("jvm_diagnostic.session_capability.status.session_established") + : t("jvm_diagnostic.session_capability.status.no_session")} {formatJVMDiagnosticTransportLabel(diagnosticTransport)} - {commandRunning ? "命令执行中" : "空闲"} + {commandRunning + ? t("jvm_diagnostic.session_capability.status.command_running") + : t("jvm_diagnostic.session_capability.status.idle")} {effectiveSession?.sessionId ? ( @@ -1093,11 +1187,13 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { {effectiveSession.sessionId} ) : ( - 创建会话后会在这里显示会话 ID。 + + {t("jvm_diagnostic.session_capability.session_id_hint")} + )} - 检查能力不会执行命令;执行命令前必须先建会话。审计历史展示最近命令记录,未建会话时也可能包含过去会话的记录。 + {t("jvm_diagnostic.session_capability.note")} {renderCapabilityContent()} @@ -1123,8 +1219,8 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => { , - "审计历史", - "最近命令和执行状态", + t("jvm_diagnostic.history.title"), + t("jvm_diagnostic.history.description"), )} variant="borderless" style={cardStyle} diff --git a/frontend/src/components/JVMMonitoringDashboard.test.tsx b/frontend/src/components/JVMMonitoringDashboard.test.tsx index 014ae8a..78bb92b 100644 --- a/frontend/src/components/JVMMonitoringDashboard.test.tsx +++ b/frontend/src/components/JVMMonitoringDashboard.test.tsx @@ -1,7 +1,9 @@ import React from "react"; +import { readFileSync } from "node:fs"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../i18n/provider"; import JVMMonitoringDashboard from "./JVMMonitoringDashboard"; vi.mock("../store", () => ({ @@ -25,9 +27,13 @@ vi.mock("../store", () => ({ }), })); -describe("JVMMonitoringDashboard", () => { - it("shows start action and empty-state guidance before monitoring starts", () => { - const markup = renderToStaticMarkup( +const renderDashboard = () => + renderToStaticMarkup( + undefined} + > { connectionId: "conn-1", providerMode: "jmx", }} - />, - ); + /> + , + ); - expect(markup).toContain("开始监控"); - expect(markup).toContain("当前尚未开始持续监控"); - expect(markup).toContain("堆内存"); - expect(markup).toContain("暂无堆内存采样数据"); +describe("JVMMonitoringDashboard", () => { + it("shows start action and empty-state guidance before monitoring starts", () => { + const markup = renderDashboard(); + + expect(markup).toContain("Continuous JVM monitoring"); + expect(markup).toContain("Stopped"); + expect(markup).toContain("Refresh"); + expect(markup).toContain("Start monitoring"); + expect(markup).toContain("Continuous monitoring has not started yet"); + expect(markup).toContain( + "After you click "Start monitoring", GoNavi keeps sampling results for this connection in the current session; switching tabs does not stop sampling.", + ); + expect(markup).toContain("Heap memory"); + expect(markup).toContain("No heap memory samples yet."); + expect(markup).not.toContain("堆内存"); + expect(markup).not.toContain("暂无堆内存采样数据"); expect(markup).not.toContain("暂无 Heap 采样数据"); expect(markup).not.toContain("当前 provider 未提供 Heap 指标"); }); it("renders a dedicated vertical scroll shell for tall monitoring content", () => { - const markup = renderToStaticMarkup( - , - ); + const markup = renderDashboard(); expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"'); expect(markup).toContain("height:100%"); @@ -66,20 +75,38 @@ describe("JVMMonitoringDashboard", () => { }); it("stacks monitoring charts before detail panels so charts keep full content width", () => { - const markup = renderToStaticMarkup( - , - ); + const markup = renderDashboard(); expect(markup).toContain('data-jvm-monitoring-content-stack="true"'); expect(markup).toContain("gap:24px"); expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)"); }); + + it("keeps dashboard-owned Chinese literals out of the component source", () => { + const source = readFileSync( + new URL("./JVMMonitoringDashboard.tsx", import.meta.url), + "utf8", + ); + + [ + "JVMGetMonitoringHistory 后端方法不可用", + "读取监控历史失败", + "连接不存在或已被删除", + "JVMStartMonitoring 后端方法不可用", + "开始监控失败", + "JVMStopMonitoring 后端方法不可用", + "停止监控失败", + "JVM 持续监控", + "采样中", + "未运行", + "刷新", + "开始监控", + "停止监控", + "监控能力存在降级", + "当前尚未开始持续监控", + "点击“开始监控”后", + ].forEach((literal) => { + expect(source).not.toContain(literal); + }); + }); }); diff --git a/frontend/src/components/JVMMonitoringDashboard.tsx b/frontend/src/components/JVMMonitoringDashboard.tsx index 4b9a2c0..4d7550c 100644 --- a/frontend/src/components/JVMMonitoringDashboard.tsx +++ b/frontend/src/components/JVMMonitoringDashboard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd"; import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons"; +import { useI18n } from "../i18n/provider"; import { useStore } from "../store"; import type { JVMMonitoringSessionState, TabData } from "../types"; import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; @@ -63,6 +64,7 @@ const resolveBackendApp = () => typeof window === "undefined" ? undefined : (window as any).go?.app?.App; const JVMMonitoringDashboard: React.FC = ({ tab }) => { + const { t, language } = useI18n(); const theme = useStore((state) => state.theme); const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId), @@ -120,7 +122,7 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) setLoading(true); if (typeof backendApp?.JVMGetMonitoringHistory !== "function") { - setError("JVMGetMonitoringHistory 后端方法不可用"); + setError(t("jvm_monitoring_dashboard.error.history_unavailable")); setLoading(false); return; } @@ -136,7 +138,9 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) } if (result?.success === false) { - const message = String(result?.message || "读取监控历史失败"); + const message = String( + result?.message || t("jvm_monitoring_dashboard.error.history_load_failed"), + ); if (isMonitoringSessionMissing(message)) { setSession(createEmptySession(tab.connectionId, providerMode)); setError(""); @@ -160,7 +164,10 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) } } catch (fetchError: any) { if (!cancelled) { - setError(fetchError?.message || "读取监控历史失败"); + setError( + fetchError?.message || + t("jvm_monitoring_dashboard.error.history_load_failed"), + ); setLoading(false); } } @@ -174,20 +181,25 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) clearTimeout(timer); } }; - }, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]); + }, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed, t]); if (!connection) { - return ; + return ( + + ); } const backendApp = resolveBackendApp(); - const availabilityText = buildMonitoringAvailabilityText(session); + const availabilityText = buildMonitoringAvailabilityText(session, language); const modeMeta = resolveJVMModeMeta(providerMode); const emptyState = !session.running && (session.points || []).length === 0; const handleStart = async () => { if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") { - setError("JVMStartMonitoring 后端方法不可用"); + setError(t("jvm_monitoring_dashboard.error.start_unavailable")); return; } @@ -196,14 +208,16 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) try { const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig); if (result?.success === false) { - throw new Error(String(result?.message || "开始监控失败")); + throw new Error( + String(result?.message || t("jvm_monitoring_dashboard.error.start_failed")), + ); } setSession( normalizeMonitoringSession(result?.data, tab.connectionId, providerMode), ); setPollSeed((current) => current + 1); } catch (startError: any) { - setError(startError?.message || "开始监控失败"); + setError(startError?.message || t("jvm_monitoring_dashboard.error.start_failed")); } finally { setActionLoading(false); } @@ -211,7 +225,7 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) const handleStop = async () => { if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") { - setError("JVMStopMonitoring 后端方法不可用"); + setError(t("jvm_monitoring_dashboard.error.stop_unavailable")); return; } @@ -223,12 +237,14 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) providerMode, ); if (result?.success === false) { - throw new Error(String(result?.message || "停止监控失败")); + throw new Error( + String(result?.message || t("jvm_monitoring_dashboard.error.stop_failed")), + ); } setSession((current) => ({ ...current, running: false })); setPollSeed((current) => current + 1); } catch (stopError: any) { - setError(stopError?.message || "停止监控失败"); + setError(stopError?.message || t("jvm_monitoring_dashboard.error.stop_failed")); } finally { setActionLoading(false); } @@ -260,7 +276,7 @@ const JVMMonitoringDashboard: React.FC = ({ tab })
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} /> - JVM 持续监控 + {t("jvm_monitoring_dashboard.title")} {connection.name} @@ -275,15 +291,17 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) {modeMeta.label} {session.running ? ( - 采样中 + + {t("jvm_monitoring_dashboard.status.sampling")} + ) : ( - 未运行 + {t("jvm_monitoring_dashboard.status.stopped")} )} {session.running ? ( ) : ( )} @@ -312,7 +330,7 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) ) : null} @@ -337,11 +355,11 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) > - 点击“开始监控”后,GoNavi 会在当前会话内持续保留该连接的采样结果;切换页签不会停止采样。 + {t("jvm_monitoring_dashboard.empty.description")} @@ -357,6 +375,7 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) points={session.points || []} session={session} darkMode={darkMode} + language={language} />
) : ( @@ -372,16 +391,19 @@ const JVMMonitoringDashboard: React.FC = ({ tab }) latestPoint={latestPoint} session={session} darkMode={darkMode} + language={language} /> )} diff --git a/frontend/src/components/JVMOverview.test.tsx b/frontend/src/components/JVMOverview.test.tsx index 33c2a58..bf4ee5f 100644 --- a/frontend/src/components/JVMOverview.test.tsx +++ b/frontend/src/components/JVMOverview.test.tsx @@ -1,65 +1,193 @@ import React from "react"; +import { readFileSync } from "node:fs"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { t } from "../i18n"; +import { I18nProvider } from "../i18n/provider"; +import type { LanguagePreference } from "../i18n/types"; import JVMOverview from "./JVMOverview"; vi.mock("../../wailsjs/go/app/App", () => ({ JVMProbeCapabilities: vi.fn(), })); +const mockState = { + connections: [] as any[], + theme: "light", +}; + vi.mock("../store", () => ({ useStore: (selector: (state: any) => any) => selector({ - connections: [ - { - id: "conn-jvm-1", - name: "orders-jvm", - config: { - host: "localhost", - port: 10990, - jvm: { - preferredMode: "jmx", - allowedModes: ["jmx", "endpoint", "agent"], - readOnly: true, - environment: "dev", - endpoint: { - enabled: true, - baseUrl: "http://localhost:8080/actuator", - }, - agent: { - enabled: true, - baseUrl: "http://localhost:8563", - }, - }, - }, - }, - ], - theme: "light", + connections: mockState.connections, + theme: mockState.theme, }), })); +const source = readFileSync(new URL("./JVMOverview.tsx", import.meta.url), "utf8"); + +const renderWithI18n = (node: React.ReactNode, preference: LanguagePreference = "en-US") => ( + + {node} + +); + +const overviewTab = { + id: "tab-jvm-overview", + type: "jvm-overview", + title: "[orders-jvm] JVM 概览", + connectionId: "conn-jvm-1", + providerMode: "jmx", +} as any; + +beforeEach(() => { + mockState.connections = [ + { + id: "conn-jvm-1", + name: "orders-jvm", + config: { + host: "localhost", + port: 10990, + jvm: { + preferredMode: "jmx", + allowedModes: ["jmx", "endpoint", "agent"], + readOnly: true, + environment: "dev", + endpoint: { + enabled: true, + baseUrl: "", + }, + agent: { + enabled: false, + baseUrl: "", + }, + }, + }, + }, + ]; + mockState.theme = "light"; +}); + describe("JVMOverview", () => { - it("renders a unified JVM workspace overview shell", () => { + it("renders a localized en-US JVM workspace overview shell", () => { const markup = renderToStaticMarkup( - , + renderWithI18n(), ); expect(markup).toContain('data-jvm-workspace-shell="true"'); expect(markup).toContain('data-jvm-workspace-hero="true"'); - expect(markup).toContain("JVM 运行时概览"); - expect(markup).toContain("连接摘要"); - expect(markup).toContain("模式能力"); - expect(markup).toContain("JMX 地址"); + [ + t("jvm_overview.eyebrow", undefined, "en-US"), + t("jvm_overview.title", undefined, "en-US"), + t("jvm_overview.badge.read_only", undefined, "en-US"), + t("jvm_overview.card.connection_summary", undefined, "en-US"), + t("jvm_overview.card.mode_capability", undefined, "en-US"), + t("jvm_overview.field.current_mode", undefined, "en-US"), + t("jvm_overview.field.allowed_modes", undefined, "en-US"), + t("jvm_overview.field.jmx_address", undefined, "en-US"), + t("jvm_overview.field.endpoint", undefined, "en-US"), + t("jvm_overview.field.agent", undefined, "en-US"), + t("jvm_overview.field.resource_browse", undefined, "en-US"), + t("jvm_overview.value.enabled", undefined, "en-US"), + t("jvm_overview.value.not_configured", undefined, "en-US"), + t("jvm_overview.value.resource_browse_lazy_load", undefined, "en-US"), + ].forEach((snippet) => { + expect(markup).toContain(snippet); + }); + expect(markup).toContain("JMX, Endpoint, Agent"); + expect(markup).toContain("orders-jvm"); + expect(markup).toContain("localhost:10990"); expect(markup).toContain("Endpoint"); expect(markup).toContain("Agent"); + + [ + "JVM 运行时概览", + "只读连接", + "连接摘要", + "模式能力", + "当前模式", + "允许模式", + "JMX 地址", + "资源浏览", + "已启用", + "未配置", + "通过侧边栏展开模式节点后懒加载", + "JMX、Endpoint、Agent", + ].forEach((snippet) => { + expect(markup).not.toContain(snippet); + }); + }); + + it("renders the missing connection empty state in en-US", () => { + mockState.connections = []; + + const markup = renderToStaticMarkup( + renderWithI18n(), + ); + + expect(markup).toContain( + t("jvm_overview.connection_missing.message", undefined, "en-US"), + ); + expect(markup).not.toContain("连接不存在或已被删除"); + }); + + it("wires async capability and fallback wrappers through existing i18n keys", () => { + [ + "已启用", + "连接不存在或已被删除", + "读取 JVM 模式能力失败", + "JVM 运行时概览", + "只读连接", + "可写连接", + "连接摘要", + "模式能力", + "当前模式", + "允许模式", + "JMX 地址", + "资源浏览", + "未配置", + "通过侧边栏展开模式节点后懒加载", + "暂无模式能力数据", + "可浏览", + "不可浏览", + "可写", + "只读", + "支持预览", + "不支持预览", + ].forEach((snippet) => { + expect(source).not.toContain(snippet); + }); + + [ + "useI18n()", + "jvm_overview.value.enabled", + "jvm_overview.connection_missing.message", + "jvm_overview.error.capability_load_failed", + "jvm_overview.title", + "jvm_overview.badge.read_only", + "jvm_overview.badge.writable", + "jvm_overview.card.connection_summary", + "jvm_overview.card.mode_capability", + "jvm_overview.field.current_mode", + "jvm_overview.field.allowed_modes", + "jvm_overview.field.jmx_address", + "jvm_overview.field.resource_browse", + "jvm_overview.value.not_configured", + "jvm_overview.value.resource_browse_lazy_load", + "jvm_overview.empty.capabilities", + "jvm_overview.capability.can_browse", + "jvm_overview.capability.cannot_browse", + "jvm_overview.capability.writable", + "jvm_overview.capability.read_only", + "jvm_overview.capability.preview_supported", + "jvm_overview.capability.preview_unsupported", + ].forEach((key) => { + expect(source).toContain(key); + }); }); }); diff --git a/frontend/src/components/JVMOverview.tsx b/frontend/src/components/JVMOverview.tsx index d69c31e..818cf2a 100644 --- a/frontend/src/components/JVMOverview.tsx +++ b/frontend/src/components/JVMOverview.tsx @@ -21,6 +21,7 @@ import { JVMWorkspaceHero, JVMWorkspaceShell, } from "./jvm/JVMWorkspaceLayout"; +import { useI18n } from "../i18n/provider"; const { Text } = Typography; const DESCRIPTION_STYLES = { label: { width: 120 } } as const; @@ -30,6 +31,7 @@ type JVMOverviewProps = { }; const JVMOverview: React.FC = ({ tab }) => { + const { language, t } = useI18n(); const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId), ); @@ -51,8 +53,8 @@ const JVMOverview: React.FC = ({ tab }) => { if (!endpoint.enabled && !endpoint.baseUrl) { return ""; } - return endpoint.baseUrl || "已启用"; - }, [connection]); + return endpoint.baseUrl || t("jvm_overview.value.enabled"); + }, [connection, t]); const agentSummary = useMemo(() => { if (!connection?.config.jvm?.agent) { @@ -62,18 +64,20 @@ const JVMOverview: React.FC = ({ tab }) => { if (!agent.enabled && !agent.baseUrl) { return ""; } - return agent.baseUrl || "已启用"; - }, [connection]); + return agent.baseUrl || t("jvm_overview.value.enabled"); + }, [connection, t]); const allowedModeSummary = useMemo(() => { const items = allowedModes.length > 0 ? allowedModes : ["jmx"]; - return items.map((item) => resolveJVMModeMeta(item).label).join("、"); - }, [allowedModes]); + const delimiter = + language.startsWith("zh") || language === "ja-JP" ? "、" : ", "; + return items.map((item) => resolveJVMModeMeta(item).label).join(delimiter); + }, [allowedModes, language]); useEffect(() => { if (!connection) { setCapabilities([]); - setCapabilityError("连接不存在或已被删除"); + setCapabilityError(t("jvm_overview.connection_missing.message")); setCapabilityLoading(false); return; } @@ -92,7 +96,9 @@ const JVMOverview: React.FC = ({ tab }) => { if (result?.success === false) { setCapabilities([]); setCapabilityError( - String(result?.message || "读取 JVM 模式能力失败"), + String( + result?.message || t("jvm_overview.error.capability_load_failed"), + ), ); return; } @@ -102,7 +108,9 @@ const JVMOverview: React.FC = ({ tab }) => { } catch (error: any) { if (!cancelled) { setCapabilities([]); - setCapabilityError(error?.message || "读取 JVM 模式能力失败"); + setCapabilityError( + error?.message || t("jvm_overview.error.capability_load_failed"), + ); } } finally { if (!cancelled) { @@ -115,11 +123,14 @@ const JVMOverview: React.FC = ({ tab }) => { return () => { cancelled = true; }; - }, [connection]); + }, [connection, t]); if (!connection) { return ( - + ); } @@ -132,8 +143,8 @@ const JVMOverview: React.FC = ({ tab }) => { {connection.name} @@ -147,42 +158,54 @@ const JVMOverview: React.FC = ({ tab }) => { <> - {readOnly ? "只读连接" : "可写连接"} + {readOnly + ? t("jvm_overview.badge.read_only") + : t("jvm_overview.badge.writable")} {connection.config.jvm?.environment || "dev"} } /> - + - + {resolveJVMModeMeta(providerMode).label} - + {allowedModeSummary} - {`${jmxHost}:${jmxPort}`} - - {endpointSummary || "未配置"} + + {`${jmxHost}:${jmxPort}`} - - {agentSummary || "未配置"} + + {endpointSummary || t("jvm_overview.value.not_configured")} - - {"通过侧边栏展开模式节点后懒加载"} + + {agentSummary || t("jvm_overview.value.not_configured")} + + + {t("jvm_overview.value.resource_browse_lazy_load")} - + {capabilityLoading ? ( ) : capabilityError ? ( {capabilityError} @@ -190,7 +213,7 @@ const JVMOverview: React.FC = ({ tab }) => { } /> ) : capabilities.length === 0 ? ( - + ) : ( {capabilities.map((capability) => ( @@ -205,13 +228,19 @@ const JVMOverview: React.FC = ({ tab }) => { - {capability.canBrowse ? "可浏览" : "不可浏览"} + {capability.canBrowse + ? t("jvm_overview.capability.can_browse") + : t("jvm_overview.capability.cannot_browse")} - {capability.canWrite ? "可写" : "只读"} + {capability.canWrite + ? t("jvm_overview.capability.writable") + : t("jvm_overview.capability.read_only")} - {capability.canPreview ? "支持预览" : "不支持预览"} + {capability.canPreview + ? t("jvm_overview.capability.preview_supported") + : t("jvm_overview.capability.preview_unsupported")} {capability.reason ? ( diff --git a/frontend/src/components/JVMResourceBrowser.interaction.test.tsx b/frontend/src/components/JVMResourceBrowser.interaction.test.tsx index b4f831d..e51cf14 100644 --- a/frontend/src/components/JVMResourceBrowser.interaction.test.tsx +++ b/frontend/src/components/JVMResourceBrowser.interaction.test.tsx @@ -4,6 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import JVMResourceBrowser from "./JVMResourceBrowser"; import type { JVMValueSnapshot } from "../types"; +import { + getCurrentLanguage, + setCurrentLanguage, + t as translate, +} from "../i18n"; +import { I18nProvider } from "../i18n/provider"; const storeState = vi.hoisted(() => ({ connections: [ @@ -37,7 +43,7 @@ const backendApp = vi.hoisted(() => ({ JVMApplyChange: vi.fn(), })); -vi.mock("@monaco-editor/react", () => ({ +vi.mock("./MonacoEditor", () => ({ default: ({ value }: { value?: string }) =>
{value}
, })); @@ -147,8 +153,39 @@ const waitForEffects = async () => { }); }; +const emitJVMApplyAIPlan = async (detail: any) => { + const eventListeners = (window.addEventListener as any).mock.calls.filter( + ([eventName]: [string]) => eventName === "gonavi:jvm-apply-ai-plan", + ); + const handler = eventListeners[eventListeners.length - 1]?.[1] as + | EventListener + | undefined; + expect(handler).toBeTruthy(); + await act(async () => { + handler!(new CustomEvent("gonavi:jvm-apply-ai-plan", { detail })); + }); +}; + +const renderWithI18n = ( + tab: typeof writableTab, + language: "zh-CN" | "en-US", +) => ( + + + +); + describe("JVMResourceBrowser interactions", () => { + let previousLanguage = getCurrentLanguage(); + beforeEach(() => { + previousLanguage = getCurrentLanguage(); + setCurrentLanguage("zh-CN"); + storeState.connections = [ { id: "conn-jvm-writable", @@ -215,12 +252,675 @@ describe("JVMResourceBrowser interactions", () => { }); afterEach(() => { + setCurrentLanguage(previousLanguage); backendApp.JVMGetValue.mockReset(); backendApp.JVMPreviewChange.mockReset(); backendApp.JVMApplyChange.mockReset(); vi.unstubAllGlobals(); }); + it("localizes resource snapshot and draft form chrome without translating raw values", async () => { + setCurrentLanguage("en-US"); + + backendApp.JVMGetValue.mockResolvedValueOnce({ + success: true, + data: { + resourceId: "jmx:/attribute/app/Mode", + kind: "attribute", + format: "string", + version: "v1", + value: "cold", + metadata: { + source: "runtime", + }, + supportedActions: [ + { + action: "set", + label: "设置属性", + description: "运行时原始说明", + payloadExample: { value: "warm" }, + payloadFields: [ + { name: "value", required: true }, + { name: "ttlSeconds" }, + ], + }, + ], + } as JVMValueSnapshot, + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await waitForEffects(); + + const text = textContent(renderer!.root); + expect(text).toContain("JVM Resource Workbench"); + expect(text).toContain("Writable connection"); + expect(findButton(renderer!, "Refresh")).toBeTruthy(); + expect(findButton(renderer!, "Audit log")).toBeTruthy(); + expect(findButton(renderer!, "Generate AI plan")).toBeTruthy(); + expect(text).toContain("Resource snapshot"); + expect(text).toContain("Resource ID"); + expect(text).toContain("Resource type"); + expect(text).toContain("Format"); + expect(text).toContain("Version"); + expect(text).toContain("Available actions"); + expect(text).toContain("Resource value"); + expect(text).toContain("Metadata"); + expect(text).toContain("Change draft"); + expect(text).toContain("Resource path"); + expect(text).toContain("Target resource"); + expect(text).toContain("Resource version"); + expect(text).toContain("Draft source"); + expect(text).toContain("Manual edit"); + expect(text).toContain("Supported resource actions"); + expect(text).toContain("Payload fields: value (required), ttlSeconds"); + expect(text).toContain("Action"); + expect(text).toContain("Current action: 设置属性"); + expect(text).toContain("Change reason"); + expect(text).toContain("Payload (JSON)"); + expect(text).toContain("Preview uses the current draft."); + expect(text).toContain( + "A recommended template has been filled for the current action.", + ); + expect(text).toContain("jmx:/attribute/app/Mode"); + expect(text).toContain("attribute"); + expect(text).toContain("string"); + expect(text).toContain("v1"); + expect(text).toContain("cold"); + expect(text).toContain("设置属性"); + expect(text).toContain("运行时原始说明"); + expect( + renderer!.root.findAllByType("input").some( + (item) => item.props.placeholder === "For example, set or invoke", + ), + ).toBe(true); + expect( + renderer!.root.findAllByType("input").some( + (item) => + item.props.placeholder === + "Enter the reason for this JVM resource change", + ), + ).toBe(true); + expect(findButton(renderer!, "Preview change")).toBeTruthy(); + expect(findButton(renderer!, "Ask AI for a plan")).toBeTruthy(); + + backendApp.JVMGetValue.mockResolvedValueOnce({ success: true, data: null }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + + expect(textContent(renderer!.root)).toContain("No resource data"); + + storeState.connections = [ + { + ...storeState.connections[0], + config: { + ...storeState.connections[0].config, + jvm: { + ...storeState.connections[0].config.jvm, + readOnly: true, + }, + }, + }, + ]; + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + + expect(textContent(renderer!.root)).toContain("Read-only connection"); + }); + + it("localizes JVM resource load error chrome without leaking raw backend detail", async () => { + setCurrentLanguage("en-US"); + + storeState.connections = []; + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + expect(textContent(renderer!.root)).toContain( + "The connection does not exist or has been deleted.", + ); + + storeState.connections = [ + { + id: "conn-jvm-writable", + name: "orders-jvm", + config: { + host: "127.0.0.1", + user: "jmx-user", + port: 9010, + type: "jvm", + jvm: { + preferredMode: "jmx", + readOnly: false, + jmx: { + password: "initial-jmx-secret", + }, + }, + }, + }, + ]; + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + go: { + app: { + App: {}, + }, + }, + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + expect(textContent(renderer!.root)).toContain( + "JVM value reading is not available in this build.", + ); + + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + go: { + app: { + App: backendApp, + }, + }, + }); + backendApp.JVMGetValue.mockResolvedValueOnce({ success: false }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + expect(textContent(renderer!.root)).toContain("Failed to read JVM resource."); + + backendApp.JVMGetValue.mockResolvedValueOnce({ + success: false, + message: "raw backend boom", + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + + const text = textContent(renderer!.root); + expect(text).toContain("Failed to read JVM resource."); + expect(text).not.toContain("raw backend boom"); + }); + + it("refreshes fallback JVM resource load errors after provider locale changes", async () => { + setCurrentLanguage("en-US"); + backendApp.JVMGetValue + .mockResolvedValueOnce({ success: false }) + .mockResolvedValueOnce({ success: false }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(renderWithI18n(writableTab, "en-US")); + }); + await waitForEffects(); + + expect(textContent(renderer!.root)).toContain( + translate("jvm_resource.error.read_failed", undefined, "en-US"), + ); + + setCurrentLanguage("zh-CN"); + await act(async () => { + renderer!.update(renderWithI18n(writableTab, "zh-CN")); + }); + await waitForEffects(); + + const text = textContent(renderer!.root); + expect(backendApp.JVMGetValue).toHaveBeenCalledTimes(2); + expect(text).toContain( + translate("jvm_resource.error.read_failed", undefined, "zh-CN"), + ); + expect(text).not.toContain( + translate("jvm_resource.error.read_failed", undefined, "en-US"), + ); + }); + + it("localizes AI-plan import and fill chrome while preserving raw resource ids", async () => { + setCurrentLanguage("en-US"); + + const rawResourceId = "jmx:/attribute/app/Mode-RAW-42"; + const tab = { + ...writableTab, + resourcePath: rawResourceId, + }; + const planContext = { + targetTabId: tab.id, + connectionId: tab.connectionId, + providerMode: tab.providerMode, + resourcePath: tab.resourcePath, + }; + const validPlan = { + targetType: "attribute", + selector: { + resourcePath: rawResourceId, + }, + action: "set", + payload: { + format: "json", + value: { value: "warm" }, + }, + reason: "Keep raw id visible", + }; + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await waitForEffects(); + + await emitJVMApplyAIPlan({ plan: validPlan }); + expect(textContent(renderer!.root)).toContain( + "The AI plan is missing its source context. Regenerate it from the target JVM resource page before applying it.", + ); + + await emitJVMApplyAIPlan({ + plan: validPlan, + ...planContext, + connectionId: "conn-other", + }); + expect(textContent(renderer!.root)).toContain( + "The current JVM tab does not match the source context of the AI plan, so automatic application was rejected.", + ); + + const fallbackPlan = { + ...validPlan, + selector: { + resourcePath: { + toString: () => { + throw null; + }, + }, + }, + }; + await emitJVMApplyAIPlan({ + plan: fallbackPlan, + ...planContext, + }); + expect(textContent(renderer!.root)).toContain( + "The AI plan cannot be converted into a JVM preview draft right now.", + ); + + const rawErrorPlan = { + ...validPlan, + selector: { + resourcePath: { + toString: () => { + throw new Error("raw plan detail"); + }, + }, + }, + }; + await emitJVMApplyAIPlan({ + plan: rawErrorPlan, + ...planContext, + }); + expect(textContent(renderer!.root)).toContain( + "The AI plan cannot be converted into a JVM preview draft right now.", + ); + expect(textContent(renderer!.root)).not.toContain("raw plan detail"); + + await emitJVMApplyAIPlan({ + plan: validPlan, + ...planContext, + }); + const text = textContent(renderer!.root); + expect(text).toContain( + `The draft was filled from the AI plan for ${rawResourceId}. Preview the change before confirming the write.`, + ); + expect(text).toContain(rawResourceId); + }); + + it("updates AI-plan listener translations after locale changes", async () => { + setCurrentLanguage("en-US"); + + const rawResourceId = "jmx:/attribute/app/Mode-RAW-42"; + const tab = { + ...writableTab, + resourcePath: rawResourceId, + }; + const planContext = { + targetTabId: tab.id, + connectionId: tab.connectionId, + providerMode: tab.providerMode, + resourcePath: tab.resourcePath, + }; + const validPlan = { + targetType: "attribute", + selector: { + resourcePath: rawResourceId, + }, + action: "set", + payload: { + format: "json", + value: { value: "warm" }, + }, + reason: "Keep raw id visible", + }; + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(renderWithI18n(tab, "en-US")); + }); + await waitForEffects(); + const firstAIPlanHandler = (window.addEventListener as any).mock.calls.find( + ([eventName]: [string]) => eventName === "gonavi:jvm-apply-ai-plan", + )?.[1]; + expect(firstAIPlanHandler).toBeTruthy(); + + setCurrentLanguage("zh-CN"); + await act(async () => { + renderer!.update(renderWithI18n(tab, "zh-CN")); + }); + await waitForEffects(); + expect(window.removeEventListener).toHaveBeenCalledWith( + "gonavi:jvm-apply-ai-plan", + firstAIPlanHandler, + ); + + await emitJVMApplyAIPlan({ plan: validPlan }); + expect(textContent(renderer!.root)).toContain( + translate("jvm_resource.error.ai_plan_missing_context"), + ); + + await emitJVMApplyAIPlan({ + plan: validPlan, + ...planContext, + }); + const text = textContent(renderer!.root); + expect(text).toContain( + translate("jvm_resource.message.ai_plan_draft_filled", { + resourceId: rawResourceId, + }), + ); + expect(text).toContain(rawResourceId); + }); + + it("localizes draft preview and apply fallbacks while preserving raw backend messages", async () => { + setCurrentLanguage("en-US"); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await waitForEffects(); + + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + expect(textContent(renderer!.root)).toContain( + translate("jvm_resource.error.reason_required", undefined, "en-US"), + ); + expect(textContent(renderer!.root)).not.toContain("请填写变更原因"); + + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + go: { + app: { + App: { + JVMGetValue: backendApp.JVMGetValue, + JVMApplyChange: backendApp.JVMApplyChange, + }, + }, + }, + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + expect(textContent(renderer!.root)).toContain( + translate("jvm_resource.error.preview_unavailable", undefined, "en-US"), + ); + + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + go: { + app: { + App: backendApp, + }, + }, + }); + backendApp.JVMPreviewChange.mockResolvedValueOnce({ success: false }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + await waitForEffects(); + expect(textContent(renderer!.root)).toContain( + translate("jvm_resource.error.preview_failed", undefined, "en-US"), + ); + + backendApp.JVMPreviewChange.mockResolvedValueOnce({ + success: false, + message: "raw preview backend detail", + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + await waitForEffects(); + const rawPreviewText = textContent(renderer!.root); + expect(rawPreviewText).toContain("raw preview backend detail"); + expect(rawPreviewText).not.toContain( + translate("jvm_resource.error.preview_failed", undefined, "en-US"), + ); + + backendApp.JVMPreviewChange.mockRejectedValueOnce( + new Error("HTTP 500 /system raw preview failure checksum=abc123"), + ); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + await waitForEffects(); + const thrownPreviewText = textContent(renderer!.root); + expect(thrownPreviewText).toContain( + "HTTP 500 /system raw preview failure checksum=abc123", + ); + expect(thrownPreviewText).not.toContain( + translate("jvm_resource.error.preview_failed", undefined, "en-US"), + ); + + backendApp.JVMPreviewChange.mockResolvedValueOnce({ + allowed: true, + requiresConfirmation: true, + confirmationToken: "token-from-preview", + summary: "preview ready", + riskLevel: "low", + }); + backendApp.JVMApplyChange.mockResolvedValueOnce({ + success: true, + data: { status: "applied" }, + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + await waitForEffects(); + await act(async () => { + findButton(renderer!, "确认执行").props.onClick(); + }); + await waitForEffects(); + expect(textContent(renderer!.root)).toContain( + translate("jvm_resource.message.apply_success", undefined, "en-US"), + ); + + backendApp.JVMPreviewChange.mockResolvedValueOnce({ + allowed: true, + requiresConfirmation: true, + confirmationToken: "token-from-preview", + summary: "preview ready", + riskLevel: "low", + }); + backendApp.JVMApplyChange.mockResolvedValueOnce({ + success: false, + message: "HTTP 409 raw apply backend detail resourceId=jmx:/internal", + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + await waitForEffects(); + await act(async () => { + findButton(renderer!, "确认执行").props.onClick(); + }); + await waitForEffects(); + const rawApplyFailureText = textContent(renderer!.root); + expect(rawApplyFailureText).toContain( + "HTTP 409 raw apply backend detail resourceId=jmx:/internal", + ); + expect(rawApplyFailureText).not.toContain( + translate("jvm_resource.error.apply_failed", undefined, "en-US"), + ); + + backendApp.JVMPreviewChange.mockResolvedValueOnce({ + allowed: true, + requiresConfirmation: true, + confirmationToken: "token-from-preview", + summary: "preview ready", + riskLevel: "low", + }); + backendApp.JVMApplyChange.mockResolvedValueOnce({ + success: true, + data: { + status: "applied", + message: "raw apply result success detail", + }, + message: "raw top-level apply success detail", + }); + await act(async () => { + renderer!.unmount(); + renderer = create(); + }); + await waitForEffects(); + await act(async () => { + renderer!.root + .findAllByType("input") + .find( + (item) => + item.props.placeholder === + translate("jvm_resource.placeholder.reason", undefined, "en-US"), + )! + .props.onChange({ target: { value: "change mode" } }); + }); + await act(async () => { + findButton(renderer!, "Preview change").props.onClick(); + }); + await waitForEffects(); + await act(async () => { + findButton(renderer!, "确认执行").props.onClick(); + }); + await waitForEffects(); + const rawApplySuccessText = textContent(renderer!.root); + expect(rawApplySuccessText).toContain("raw apply result success detail"); + expect(rawApplySuccessText).not.toContain( + translate("jvm_resource.message.apply_success", undefined, "en-US"), + ); + }); + it("applies the latest successful preview request even when the draft is edited afterward", async () => { let renderer: ReactTestRenderer; await act(async () => { diff --git a/frontend/src/components/JVMResourceBrowser.tsx b/frontend/src/components/JVMResourceBrowser.tsx index 859f5c6..5ee97c8 100644 --- a/frontend/src/components/JVMResourceBrowser.tsx +++ b/frontend/src/components/JVMResourceBrowser.tsx @@ -30,6 +30,8 @@ import type { TabData, } from "../types"; import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig"; +import { t as translate } from "../i18n"; +import { useOptionalI18n } from "../i18n/provider"; import { buildJVMChangeDraftFromAIPlan, buildJVMAIPlanPrompt, @@ -67,6 +69,26 @@ type JVMResourceBrowserProps = { tab: TabData; }; +type LocalizedError = Error & { + userMessage?: string; +}; + +const createLocalizedError = (message: string): LocalizedError => { + const error = new Error(message) as LocalizedError; + error.userMessage = message; + return error; +}; + +const resolveLocalizedErrorMessage = ( + error: unknown, + fallback: string, +): string => { + const userMessage = (error as LocalizedError | undefined)?.userMessage; + return typeof userMessage === "string" && userMessage.trim() + ? userMessage + : fallback; +}; + const buildJVMRuntimeConfig = ( connection: SavedConnection, providerMode: string, @@ -194,6 +216,10 @@ const normalizeApplyResult = (value: any): JVMApplyResult | null => { }; const JVMResourceBrowser: React.FC = ({ tab }) => { + const i18n = useOptionalI18n(); + const i18nLanguage = i18n?.language; + const tr = (key: string, params?: Parameters[1]) => + translate(key, params, i18nLanguage); const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId), ); @@ -234,6 +260,8 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { ); const [previewContextKey, setPreviewContextKey] = useState(""); const [applyLoading, setApplyLoading] = useState(false); + const snapshotLoadSequenceRef = useRef(0); + const i18nLanguageRef = useRef(i18nLanguage); const previewSequenceRef = useRef(0); const currentPreviewContextKey = buildJVMPreviewContextKey( tab.connectionId, @@ -242,6 +270,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { runtimeFingerprint, ); const previewContextKeyRef = useRef(currentPreviewContextKey); + i18nLanguageRef.current = i18nLanguage; previewContextKeyRef.current = currentPreviewContextKey; const clearPreviewState = () => { @@ -284,23 +313,25 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { [action, supportedActions], ); const selectedActionDisplay = useMemo( - () => resolveJVMActionDisplay(selectedActionDefinition || action), - [action, selectedActionDefinition], + () => + resolveJVMActionDisplay(selectedActionDefinition || action, i18nLanguage), + [action, i18nLanguage, selectedActionDefinition], ); const loadSnapshot = async () => { const loadContextKey = currentPreviewContextKey; + const loadLanguage = i18nLanguage; if (!connection) { setLoading(false); setSnapshot(null); - setError("连接不存在或已被删除"); + setError(tr("jvm_resource.error.connection_missing")); return; } if (!resourcePath) { setLoading(false); setSnapshot(null); - setError("资源路径为空"); + setError(tr("jvm_resource.error.resource_path_empty")); return; } @@ -308,10 +339,11 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (typeof backendApp?.JVMGetValue !== "function") { setLoading(false); setSnapshot(null); - setError("JVMGetValue 后端方法不可用"); + setError(tr("jvm_resource.error.get_value_unavailable")); return; } + const loadSequence = ++snapshotLoadSequenceRef.current; setLoading(true); setError(""); try { @@ -319,26 +351,43 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { buildJVMRuntimeConfig(connection, providerMode), resourcePath, ); - if (loadContextKey !== previewContextKeyRef.current) { + if ( + loadSequence !== snapshotLoadSequenceRef.current || + loadContextKey !== previewContextKeyRef.current || + loadLanguage !== i18nLanguageRef.current + ) { return; } if (!result?.success) { setSnapshot(null); - setError(String(result?.message || "读取 JVM 资源失败")); + setError(tr("jvm_resource.error.read_failed")); return; } setSnapshot((result.data || null) as JVMValueSnapshot | null); } catch (err: any) { + if ( + loadSequence !== snapshotLoadSequenceRef.current || + loadContextKey !== previewContextKeyRef.current || + loadLanguage !== i18nLanguageRef.current + ) { + return; + } setSnapshot(null); - setError(err?.message || "读取 JVM 资源失败"); + setError(tr("jvm_resource.error.read_failed")); } finally { - setLoading(false); + if ( + loadSequence === snapshotLoadSequenceRef.current && + loadContextKey === previewContextKeyRef.current && + loadLanguage === i18nLanguageRef.current + ) { + setLoading(false); + } } }; useEffect(() => { void loadSnapshot(); - }, [connection, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]); + }, [connection, i18nLanguage, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]); useEffect(() => { setSnapshot(null); @@ -400,18 +449,14 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { : undefined; if (!planContext) { - setDraftError( - "AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。", - ); + setDraftError(tr("jvm_resource.error.ai_plan_missing_context")); setApplyMessage(""); clearPreviewState(); return; } if (!matchesJVMAIPlanTargetTab(tab, planContext)) { - setDraftError( - "当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。", - ); + setDraftError(tr("jvm_resource.error.ai_plan_context_mismatch")); setApplyMessage(""); clearPreviewState(); return; @@ -420,8 +465,8 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { let draftFromPlan: JVMAIChangeDraft; try { draftFromPlan = buildJVMChangeDraftFromAIPlan(plan); - } catch (err: any) { - setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿"); + } catch { + setDraftError(tr("jvm_resource.error.ai_plan_to_draft_failed")); setApplyMessage(""); clearPreviewState(); return; @@ -434,7 +479,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { setDraftSource(draftFromPlan.source || "ai-plan"); setDraftError(""); setApplyMessage( - `已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`, + tr("jvm_resource.message.ai_plan_draft_filled", { + resourceId: draftFromPlan.resourceId, + }), ); clearPreviewState(); }; @@ -448,7 +495,14 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { "gonavi:jvm-apply-ai-plan", handler as EventListener, ); - }, [resourcePath, tab.id]); + }, [ + i18nLanguage, + resourcePath, + tab.connectionId, + tab.id, + tab.providerMode, + tab.type, + ]); const handleSelectAction = ( nextAction: string, @@ -473,7 +527,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { const trimmedAction = String(action || "").trim() || "put"; const trimmedReason = String(reason || "").trim(); if (!trimmedReason) { - throw new Error("请填写变更原因"); + throw createLocalizedError(tr("jvm_resource.error.reason_required")); } const rawPayload = String(payloadText || "").trim(); @@ -481,14 +535,16 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (rawPayload) { const parsed = JSON.parse(rawPayload); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("Payload 必须是 JSON 对象"); + throw createLocalizedError( + tr("jvm_resource.error.payload_object_required"), + ); } payload = parsed as Record; } const resourceId = String(draftResourceId || resourcePath).trim(); if (!resourceId) { - throw new Error("资源 ID 为空,无法生成变更草稿"); + throw createLocalizedError(tr("jvm_resource.error.resource_id_empty")); } return { @@ -518,7 +574,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { const handleAskAIForPlan = () => { if (!connection) { - setDraftError("连接不存在或已被删除"); + setDraftError(tr("jvm_resource.error.connection_missing")); return; } @@ -549,21 +605,26 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { const handlePreview = async () => { if (!connection) { - setDraftError("连接不存在或已被删除"); + setDraftError(tr("jvm_resource.error.connection_missing")); return; } const backendApp = (window as any).go?.app?.App; if (typeof backendApp?.JVMPreviewChange !== "function") { - setDraftError("JVMPreviewChange 后端方法不可用"); + setDraftError(tr("jvm_resource.error.preview_unavailable")); return; } let draftPlan: JVMChangeRequest; try { draftPlan = buildDraftPlan(); - } catch (err: any) { - setDraftError(err?.message || "变更草稿不合法"); + } catch (err) { + setDraftError( + resolveLocalizedErrorMessage( + err, + tr("jvm_resource.error.draft_invalid"), + ), + ); return; } @@ -588,14 +649,16 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (result?.success === false) { clearPreviewState(); - setDraftError(String(result?.message || "预览 JVM 变更失败")); + setDraftError( + String(result?.message || tr("jvm_resource.error.preview_failed")), + ); return; } const preview = normalizePreviewResult(result); if (!preview) { clearPreviewState(); - setDraftError("预览结果格式不正确"); + setDraftError(tr("jvm_resource.error.preview_result_invalid")); return; } @@ -606,7 +669,11 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { setPreviewOpen(true); } catch (err: any) { clearPreviewState(); - setDraftError(err?.message || "预览 JVM 变更失败"); + setDraftError( + err?.message || + (typeof err === "string" ? err : "") || + tr("jvm_resource.error.preview_failed"), + ); } finally { setPreviewLoading(false); } @@ -616,31 +683,31 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { await Promise.resolve(); if (!connection) { - setDraftError("连接不存在或已被删除"); + setDraftError(tr("jvm_resource.error.connection_missing")); return; } const backendApp = (window as any).go?.app?.App; if (typeof backendApp?.JVMApplyChange !== "function") { - setDraftError("JVMApplyChange 后端方法不可用"); + setDraftError(tr("jvm_resource.error.apply_unavailable")); return; } if (!previewResult || !previewRequest || !previewRuntimeConfig) { - setDraftError("请先预览变更,再确认执行"); + setDraftError(tr("jvm_resource.error.preview_required")); return; } if (previewContextKey !== previewContextKeyRef.current) { clearPreviewState(); - setDraftError("资源上下文已变化,请重新预览后再执行"); + setDraftError(tr("jvm_resource.error.context_changed")); return; } let applyRequest: JVMChangeRequest; try { applyRequest = buildJVMPreviewApplyRequest(previewRequest, previewResult); - } catch (err: any) { - setDraftError(err?.message || "确认令牌缺失,请重新预览后再执行"); + } catch { + setDraftError(tr("jvm_resource.error.confirmation_missing")); return; } @@ -653,7 +720,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { applyRequest, ); if (result?.success === false) { - setDraftError(String(result?.message || "执行 JVM 变更失败")); + setDraftError( + String(result?.message || tr("jvm_resource.error.apply_failed")), + ); return; } @@ -664,11 +733,17 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { clearPreviewState(); setApplyMessage( - applyResult?.message || result?.message || "JVM 变更已执行", + applyResult?.message || + result?.message || + tr("jvm_resource.message.apply_success"), ); await loadSnapshot(); } catch (err: any) { - setDraftError(err?.message || "执行 JVM 变更失败"); + setDraftError( + err?.message || + (typeof err === "string" ? err : "") || + tr("jvm_resource.error.apply_failed"), + ); } finally { setApplyLoading(false); } @@ -676,7 +751,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { if (!connection) { return ( - + ); } @@ -716,7 +791,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { {connection.name} @@ -727,7 +802,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { <> - {readOnly ? "只读连接" : "可写连接"} + {readOnly + ? tr("jvm_resource.badge.read_only") + : tr("jvm_resource.badge.writable")} } @@ -738,21 +815,21 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { icon={} onClick={() => void loadSnapshot()} > - 刷新 + {tr("common.refresh")} } @@ -769,7 +846,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { }} > = ({ tab }) => { size="small" styles={DESCRIPTION_STYLES} > - + {snapshot.resourceId || "-"} - + {snapshot.kind || tab.resourceKind || "-"} - + {snapshot.format || "-"} - + {snapshot.version || "-"} - - {formatJVMActionSummary(supportedActions)} + + {formatJVMActionSummary(supportedActions, i18nLanguage)} {snapshot.description ? ( @@ -812,7 +889,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { strong style={{ display: "block", marginBottom: 8 }} > - 资源值 + {tr("jvm_resource.section.resource_value")}
= ({ tab }) => { strong style={{ display: "block", marginBottom: 8 }} > - 元数据 + {tr("jvm_resource.section.metadata")}
= ({ tab }) => { ) : null} ) : error ? null : ( - + )} )} {!readOnly ? ( - + {draftError ? ( @@ -902,17 +979,19 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { size="small" styles={DESCRIPTION_STYLES} > - + {resourcePath || "-"} - + {draftResourceId || resourcePath || "-"} - + {snapshot?.version || "-"} - - {draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"} + + {draftSource === "ai-plan" + ? tr("jvm_resource.draft_source.ai_plan") + : tr("jvm_resource.draft_source.manual")} {supportedActions.length > 0 ? ( @@ -921,7 +1000,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { size={8} style={{ width: "100%" }} > - 资源支持动作 + + {tr("jvm_resource.section.supported_actions")} + {supportedActions.map((item) => ( ))} @@ -942,19 +1023,23 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { ) : null} {selectedActionDefinition?.payloadFields?.length ? ( - Payload 字段: + {tr("jvm_resource.field.payload_fields")} {selectedActionDefinition.payloadFields .map( (field) => - `${field.name}${field.required ? "(必填)" : ""}`, + `${field.name}${ + field.required + ? tr("jvm_resource.marker.required_suffix") + : "" + }`, ) - .join("、")} + .join(tr("jvm_resource.list_separator"))} ) : null} ) : null} - 动作 + {tr("jvm_resource.field.action")} @@ -965,34 +1050,37 @@ const JVMResourceBrowser: React.FC = ({ tab }) => { } placeholder={ providerMode === "jmx" - ? "例如 set 或 invoke" - : "例如 put / clear / evict" + ? tr("jvm_resource.placeholder.action_jmx") + : tr("jvm_resource.placeholder.action_default") } maxLength={64} /> {action ? ( - 当前动作: - {formatJVMActionDisplayText(selectedActionDisplay)} + {tr("jvm_resource.message.current_action")} + {formatJVMActionDisplayText( + selectedActionDisplay, + i18nLanguage, + )} ) : null} - 变更原因 + {tr("jvm_resource.field.reason")} setReason(event.target.value)} - placeholder="填写本次 JVM 资源变更原因" + placeholder={tr("jvm_resource.placeholder.reason")} maxLength={200} /> - Payload(JSON) + {tr("jvm_resource.field.payload")} - 预览会使用当前草稿;确认执行会使用最近一次成功预览的 - request,修改草稿后请重新预览。 - {selectedActionDefinition?.payloadExample && !snapshot?.sensitive - ? " 已按当前动作填充推荐模板。" + {tr("jvm_resource.message.payload_hint")} + {selectedActionDefinition?.payloadExample && + !snapshot?.sensitive + ? ` ${tr("jvm_resource.message.payload_template_applied")}` : ""}