From 71989af5865844be8d2b6df4515b09ce9731ceb2 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:26:20 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(i18n):=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=20dev=20=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E5=A4=9A=E8=AF=AD=E8=A8=80=E9=81=97=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/store.test.ts | 27 ++ frontend/src/store.ts | 4 +- frontend/src/utils/exportProgress.test.ts | 34 +- frontend/src/utils/exportProgress.ts | 11 +- frontend/src/utils/sqlAnalysisTab.test.ts | 33 +- frontend/src/utils/sqlAnalysisTab.ts | 8 +- internal/ai/provider/codebuddy_cli.go | 40 +- internal/ai/provider/cursor_agent.go | 58 +-- internal/ai/provider/custom.go | 2 +- .../provider_cursor_codebuddy_i18n_test.go | 373 ++++++++++++++++++ internal/ai/service/service.go | 28 +- .../ai/service/service_cursor_i18n_test.go | 61 +++ internal/ai/service/service_i18n_test.go | 2 +- .../ai/service/service_session_i18n_test.go | 116 ++++++ internal/ai/service/service_test.go | 2 +- internal/app/methods_db.go | 25 +- internal/app/methods_db_conn_test.go | 50 ++- internal/app/methods_db_i18n_test.go | 11 +- internal/app/methods_db_oracle_i18n_test.go | 141 +++++++ internal/app/methods_explain.go | 63 ++- .../app/methods_explain_backend_i18n_test.go | 174 ++++++++ internal/app/methods_file.go | 97 +++-- internal/app/methods_file_export_test.go | 12 +- internal/app/methods_file_i18n_test.go | 93 +++++ internal/app/methods_redis.go | 4 +- internal/app/methods_redis_i18n_test.go | 11 + internal/app/methods_redis_test.go | 46 ++- shared/i18n/de-DE.json | 30 +- shared/i18n/en-US.json | 30 +- shared/i18n/ja-JP.json | 30 +- shared/i18n/ru-RU.json | 30 +- shared/i18n/zh-CN.json | 30 +- shared/i18n/zh-TW.json | 30 +- 33 files changed, 1556 insertions(+), 150 deletions(-) create mode 100644 internal/ai/provider/provider_cursor_codebuddy_i18n_test.go create mode 100644 internal/ai/service/service_cursor_i18n_test.go create mode 100644 internal/ai/service/service_session_i18n_test.go create mode 100644 internal/app/methods_db_oracle_i18n_test.go create mode 100644 internal/app/methods_explain_backend_i18n_test.go diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 211f476..30b5506 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -1030,6 +1030,27 @@ describe('store appearance persistence', () => { query: 'select 1;', }, ], + tableExportHistories: { + 'conn-1::main::users': [ + { + jobId: 'job-1', + targetName: ' ', + startedAt: 1, + finishedAt: 0, + format: 'csv', + scope: 'table', + scopeLabel: 'Table', + strategyLabel: 'Export', + status: 'running', + stage: '', + current: 0, + total: 0, + totalRowsKnown: false, + filePath: '', + message: '', + }, + ], + }, activeTabId: 'query-empty-title', }, version: 11, @@ -1049,6 +1070,11 @@ describe('store appearance persistence', () => { expect(reloaded.useStore.getState().tabs[0]?.title).toBe( reloadedI18n.t('sidebar.tab.new_query'), ); + expect( + reloaded.useStore.getState().tableExportHistories['conn-1::main::users']?.[0]?.targetName, + ).toBe( + reloadedI18n.t('data_export.progress.value.target_fallback'), + ); }); it('uses localized AI session fallback titles for non-user first messages', async () => { @@ -1080,6 +1106,7 @@ describe('store appearance persistence', () => { expect(source).not.toContain('`连接-${index + 1}`'); expect(source).not.toContain('`标签-${index + 1}`'); expect(source).not.toContain('`片段-${index + 1}`'); + expect(source).not.toContain('"未命名对象"'); expect(source).not.toContain('"新建查询"'); expect(source).not.toContain('"新的对话"'); }); diff --git a/frontend/src/store.ts b/frontend/src/store.ts index be711bf..474c8ec 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -1579,7 +1579,9 @@ const sanitizeTableExportHistoryEntry = ( : "idle"; return { jobId, - targetName: toTrimmedString(raw.targetName, "未命名对象") || "未命名对象", + targetName: + toTrimmedString(raw.targetName, translate("data_export.progress.value.target_fallback")) || + translate("data_export.progress.value.target_fallback"), startedAt: normalizeTimestamp(raw.startedAt), finishedAt: normalizeTimestamp(raw.finishedAt), format: toTrimmedString(raw.format).slice(0, 32), diff --git a/frontend/src/utils/exportProgress.test.ts b/frontend/src/utils/exportProgress.test.ts index 47e946e..0ca4e9b 100644 --- a/frontend/src/utils/exportProgress.test.ts +++ b/frontend/src/utils/exportProgress.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import { setCurrentLanguage, t } from '../i18n'; import { formatExportElapsed, formatExportProgressRows, @@ -9,6 +10,10 @@ import { } from './exportProgress'; describe('exportProgress', () => { + afterEach(() => { + setCurrentLanguage('zh-CN'); + }); + it('uses actual percent when total row count is known', () => { expect(resolveExportProgressPercent('running', 25, 100, true)).toBe(25); }); @@ -21,15 +26,26 @@ describe('exportProgress', () => { }); it('falls back to indeterminate progress when total row hint is zero', () => { + setCurrentLanguage('en-US'); expect(resolveExportProgressPercent('running', 754000, 0, true)).toBe(0); expect(shouldUseExactExportProgress('running', 0, true)).toBe(false); expect(shouldUseIndeterminateExportProgress('running', 0, true)).toBe(true); - expect(formatExportProgressRows(754000, 0, true)).toBe('已写入 754,000 行'); + expect(formatExportProgressRows(754000, 0, true)).toBe( + t('data_export.progress.rows_written', { current: '754,000' }), + ); }); - it('formats row summary for known and unknown totals', () => { - expect(formatExportProgressRows(12345, 0, false)).toBe('已写入 12,345 行'); - expect(formatExportProgressRows(12345, 880000, true)).toBe('已写入 12,345 / 880,000 行'); + it('formats row summary with localized text and number separators', () => { + setCurrentLanguage('de-DE'); + expect(formatExportProgressRows(12345, 0, false)).toBe( + t('data_export.progress.rows_written', { current: '12.345' }), + ); + expect(formatExportProgressRows(12345, 880000, true)).toBe( + t('data_export.progress.rows_written_with_total', { + current: '12.345', + total: '880.000', + }), + ); }); it('resolves and formats elapsed export duration', () => { @@ -38,4 +54,12 @@ describe('exportProgress', () => { expect(formatExportElapsed(30_500)).toBe('00:30'); expect(formatExportElapsed(3_723_000)).toBe('01:02:03'); }); + + it('keeps export progress source free of hard-coded Chinese row summaries', async () => { + const { readFileSync } = await import('node:fs'); + const source = readFileSync(new URL('./exportProgress.ts', import.meta.url), 'utf8'); + + expect(source).not.toContain('已写入 '); + expect(source).not.toContain("Intl.NumberFormat('zh-CN')"); + }); }); diff --git a/frontend/src/utils/exportProgress.ts b/frontend/src/utils/exportProgress.ts index 7c1752f..56d6275 100644 --- a/frontend/src/utils/exportProgress.ts +++ b/frontend/src/utils/exportProgress.ts @@ -1,3 +1,5 @@ +import { getCurrentLanguage, t } from '../i18n'; + export type ExportProgressStatus = 'idle' | 'start' | 'running' | 'finalizing' | 'done' | 'error'; const hasUsableExportTotal = (total: number, totalRowsKnown: boolean): boolean => { @@ -54,13 +56,16 @@ export const formatExportProgressRows = ( total: number, totalRowsKnown: boolean, ): string => { - const formatter = new Intl.NumberFormat('zh-CN'); + const formatter = new Intl.NumberFormat(getCurrentLanguage()); const safeCurrent = formatter.format(Math.max(0, Math.trunc(Number(current) || 0))); if (!hasUsableExportTotal(total, totalRowsKnown)) { - return `已写入 ${safeCurrent} 行`; + return t('data_export.progress.rows_written', { current: safeCurrent }); } const safeTotal = formatter.format(Math.max(0, Math.trunc(Number(total) || 0))); - return `已写入 ${safeCurrent} / ${safeTotal} 行`; + return t('data_export.progress.rows_written_with_total', { + current: safeCurrent, + total: safeTotal, + }); }; export const resolveExportElapsedMs = ( diff --git a/frontend/src/utils/sqlAnalysisTab.test.ts b/frontend/src/utils/sqlAnalysisTab.test.ts index 9bd43ba..31f3d17 100644 --- a/frontend/src/utils/sqlAnalysisTab.test.ts +++ b/frontend/src/utils/sqlAnalysisTab.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { setCurrentLanguage, t } from '../i18n' import { buildSqlAnalysisWorkbenchTab, resolveSqlAnalysisWorkbenchTabId } from './sqlAnalysisTab' describe('sqlAnalysisTab', () => { + afterEach(() => { + setCurrentLanguage('zh-CN') + }) + it('builds a stable workbench tab per connection and database', () => { expect(resolveSqlAnalysisWorkbenchTabId('conn-1', 'analytics')).toBe('sql-analysis-conn-1-analytics') expect(resolveSqlAnalysisWorkbenchTabId('conn-1')).toBe('sql-analysis-conn-1-default') @@ -18,7 +23,7 @@ describe('sqlAnalysisTab', () => { expect(tab).toMatchObject({ id: 'sql-analysis-conn-1-analytics', - title: 'SQL 分析 · analytics', + title: t('sql_analysis.workbench.tab_title_with_database', { database: 'analytics' }), type: 'sql-analysis', connectionId: 'conn-1', dbName: 'analytics', @@ -39,4 +44,28 @@ describe('sqlAnalysisTab', () => { expect(tab.sqlAnalysisView).toBe('slow-query') expect(tab.sqlAnalysisRequestKey).toBe('slow-1') }) + + it('localizes default sql analysis tab titles', () => { + setCurrentLanguage('en-US') + + expect(buildSqlAnalysisWorkbenchTab({ + connectionId: 'conn-1', + dbName: 'analytics', + }).title).toBe( + t('sql_analysis.workbench.tab_title_with_database', { database: 'analytics' }), + ) + expect(buildSqlAnalysisWorkbenchTab({ + connectionId: 'conn-1', + }).title).toBe( + t('sql_analysis.workbench.tab_title'), + ) + }) + + it('keeps sql analysis tab source free of hard-coded Chinese titles', async () => { + const { readFileSync } = await import('node:fs') + const source = readFileSync(new URL('./sqlAnalysisTab.ts', import.meta.url), 'utf8') + + expect(source).not.toContain('SQL 分析 ·') + expect(source).not.toContain("'SQL 分析'") + }) }) diff --git a/frontend/src/utils/sqlAnalysisTab.ts b/frontend/src/utils/sqlAnalysisTab.ts index dd74b86..97c556d 100644 --- a/frontend/src/utils/sqlAnalysisTab.ts +++ b/frontend/src/utils/sqlAnalysisTab.ts @@ -1,4 +1,5 @@ import type { TabData } from '../types' +import { t } from '../i18n' export type SqlAnalysisView = 'diagnose' | 'slow-query' @@ -26,12 +27,15 @@ export const buildSqlAnalysisWorkbenchTab = ( const connectionId = String(input.connectionId || '').trim() const dbName = String(input.dbName || '').trim() const view = input.view === 'slow-query' ? 'slow-query' : 'diagnose' - const title = String(input.title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析')).trim() + const defaultTitle = dbName + ? t('sql_analysis.workbench.tab_title_with_database', { database: dbName }) + : t('sql_analysis.workbench.tab_title') + const title = String(input.title || defaultTitle).trim() const query = typeof input.query === 'string' ? input.query : '' return { id: resolveSqlAnalysisWorkbenchTabId(connectionId, dbName || undefined), - title: title || (dbName ? `SQL 分析 · ${dbName}` : 'SQL 分析'), + title: title || defaultTitle, type: 'sql-analysis', connectionId, ...(dbName ? { dbName } : {}), diff --git a/internal/ai/provider/codebuddy_cli.go b/internal/ai/provider/codebuddy_cli.go index 4bfb169..ab3cd51 100644 --- a/internal/ai/provider/codebuddy_cli.go +++ b/internal/ai/provider/codebuddy_cli.go @@ -95,14 +95,14 @@ func (p *CodeBuddyCLIProvider) ChatWithState(ctx context.Context, state json.Raw output, err := cmd.Output() if err != nil { if isClaudeCLITimeout(ctx, err) { - requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) + requestErr = fmt.Errorf("CodeBuddy CLI timed out (%s); the current login session, Base URL, or API Key may not be returning a valid response", codebuddyCLIRequestTimeout) return nil, nil, requestErr } if exitErr, ok := err.(*exec.ExitError); ok { - requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr)) + requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %s", string(exitErr.Stderr)) return nil, nil, requestErr } - requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err) + requestErr = fmt.Errorf("CodeBuddy CLI execution failed: %w", err) return nil, nil, requestErr } @@ -184,7 +184,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume stdout, err := cmd.StdoutPipe() if err != nil { - requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err) + requestErr = fmt.Errorf("create stdout pipe failed: %w", err) return "", requestErr } @@ -192,7 +192,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume cmd.Stderr = &stderrBuf if err := cmd.Start(); err != nil { - requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err) + requestErr = fmt.Errorf("start CodeBuddy CLI failed: %w", err) return "", requestErr } @@ -224,7 +224,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume if isCodeBuddyCLISystemRetryEvent(event) { if errMsg, hasError := extractCodeBuddyCLISystemRetryError(event); hasError { callback(ai.StreamChunk{Error: errMsg, Done: true}) - requestErr = fmt.Errorf("CodeBuddy CLI 鉴权失败: %s", errMsg) + requestErr = fmt.Errorf("CodeBuddy CLI authentication failed: %s", errMsg) if cmd.Process != nil { _ = cmd.Process.Kill() } @@ -235,7 +235,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume case "assistant": if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { callback(ai.StreamChunk{Error: errMsg, Done: true}) - requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + requestErr = fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg) _ = cmd.Wait() return "", nil } @@ -257,7 +257,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume case "result": if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { callback(ai.StreamChunk{Error: errMsg, Done: true}) - requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + requestErr = fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg) _ = cmd.Wait() return "", nil } @@ -267,7 +267,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume case "error": errMsg, _ := extractCodeBuddyCLIEventError(event) callback(ai.StreamChunk{Error: errMsg, Done: true}) - requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + requestErr = fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg) _ = cmd.Wait() return "", nil } @@ -277,7 +277,7 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume stderrStr := strings.TrimSpace(stderrBuf.String()) if isClaudeCLITimeout(ctx, waitErr) { - requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s),当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout) + requestErr = fmt.Errorf("CodeBuddy CLI timed out (%s); the current login session, Base URL, or API Key may not be returning a valid response", codebuddyCLIRequestTimeout) callback(ai.StreamChunk{ Error: requestErr.Error(), Done: true, @@ -286,9 +286,9 @@ func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resume } if waitErr != nil { - errMsg := fmt.Sprintf("CodeBuddy CLI 异常退出: %v", waitErr) + errMsg := fmt.Sprintf("CodeBuddy CLI exited unexpectedly: %v", waitErr) if stderrStr != "" { - errMsg = fmt.Sprintf("CodeBuddy CLI 异常退出: %s", stderrStr) + errMsg = fmt.Sprintf("CodeBuddy CLI exited unexpectedly: %s", stderrStr) } requestErr = fmt.Errorf("%s", errMsg) callback(ai.StreamChunk{Error: errMsg, Done: true}) @@ -307,7 +307,7 @@ func parseCodeBuddySessionState(state json.RawMessage) (codebuddySessionState, e var sessionState codebuddySessionState if err := json.Unmarshal(trimmed, &sessionState); err != nil { - return codebuddySessionState{}, fmt.Errorf("解析 CodeBuddy 会话状态失败: %w", err) + return codebuddySessionState{}, fmt.Errorf("parse CodeBuddy session state failed: %w", err) } sessionState.SessionID = strings.TrimSpace(sessionState.SessionID) return sessionState, nil @@ -321,7 +321,7 @@ func marshalCodeBuddySessionState(sessionID string) (json.RawMessage, error) { payload, err := json.Marshal(codebuddySessionState{SessionID: sessionID}) if err != nil { - return nil, fmt.Errorf("序列化 CodeBuddy 会话状态失败: %w", err) + return nil, fmt.Errorf("serialize CodeBuddy session state failed: %w", err) } return json.RawMessage(payload), nil } @@ -332,7 +332,7 @@ func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, return command, nil } } - return "", fmt.Errorf("未找到 codebuddy 命令,请先安装 CodeBuddy CLI: npm install -g @tencent/codebuddy") + return "", fmt.Errorf("CodeBuddy CLI command not found. Install it first: npm install -g @tencent/codebuddy") } func codebuddyCLIEndpointForLog(config ai.ProviderConfig) string { @@ -384,7 +384,7 @@ func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatRespo for _, event := range events { if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError { - return nil, "", fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg) + return nil, "", fmt.Errorf("CodeBuddy CLI returned an error: %s", errMsg) } if strings.TrimSpace(event.Result) != "" { resultText = strings.TrimSpace(event.Result) @@ -451,7 +451,7 @@ func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string if exists(configured) { return configured, nil } - return "", fmt.Errorf("CodeBuddy CLI 在 Windows 下配置的 CODEBUDDY_CODE_GIT_BASH_PATH 不存在: %s", configured) + return "", fmt.Errorf("Configured CODEBUDDY_CODE_GIT_BASH_PATH does not exist on Windows: %s", configured) } for _, command := range []string{"bash.exe", "bash"} { @@ -500,7 +500,7 @@ func extractCodeBuddyCLIEventError(event cliStreamEvent) (string, bool) { return msg, true } - return "CodeBuddy CLI 返回未知错误", true + return "CodeBuddy CLI returned an unknown error", true } func isCodeBuddyCLISystemRetryEvent(event cliStreamEvent) bool { @@ -522,7 +522,7 @@ func extractCodeBuddyCLISystemRetryError(event cliStreamEvent) (string, bool) { } if event.ErrorStatus > 0 { - return fmt.Sprintf("CodeBuddy CLI 鉴权失败 (HTTP %d): %s", event.ErrorStatus, errText), true + return fmt.Sprintf("CodeBuddy CLI authentication failed (HTTP %d): %s", event.ErrorStatus, errText), true } - return fmt.Sprintf("CodeBuddy CLI 鉴权失败: %s", errText), true + return fmt.Sprintf("CodeBuddy CLI authentication failed: %s", errText), true } diff --git a/internal/ai/provider/cursor_agent.go b/internal/ai/provider/cursor_agent.go index f158b82..5cce812 100644 --- a/internal/ai/provider/cursor_agent.go +++ b/internal/ai/provider/cursor_agent.go @@ -64,7 +64,7 @@ func (p *CursorAgentProvider) Name() string { func (p *CursorAgentProvider) Validate() error { if strings.TrimSpace(p.config.APIKey) == "" { - return fmt.Errorf("API Key 不能为空") + return fmt.Errorf("API key is required") } return nil } @@ -234,7 +234,7 @@ func (p *CursorAgentProvider) ChatWithState(ctx context.Context, state json.RawM sessionState.LastRunID = runID nextState, err := json.Marshal(sessionState) if err != nil { - return nil, nil, fmt.Errorf("序列化 Cursor 会话状态失败: %w", err) + return nil, nil, fmt.Errorf("serialize Cursor session state failed: %w", err) } return &ai.ChatResponse{ @@ -344,19 +344,19 @@ func (p *CursorAgentProvider) ChatStreamWithState(ctx context.Context, state jso } case "error": if payload == "" { - callback(ai.StreamChunk{Error: "Cursor 流式请求失败", Done: true}) + callback(ai.StreamChunk{Error: "Cursor stream request failed", Done: true}) completedExplicitly = true return true, nil } var event cursorErrorEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { - callback(ai.StreamChunk{Error: "Cursor 流式请求失败", Done: true}) + callback(ai.StreamChunk{Error: "Cursor stream request failed", Done: true}) completedExplicitly = true return true, nil } errMessage := strings.TrimSpace(event.Message) if errMessage == "" { - errMessage = "Cursor 流式请求失败" + errMessage = "Cursor stream request failed" } callback(ai.StreamChunk{Error: errMessage, Done: true}) completedExplicitly = true @@ -390,7 +390,7 @@ func (p *CursorAgentProvider) ChatStreamWithState(ctx context.Context, state jso } if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("读取 Cursor 流式响应失败: %w", err) + return nil, fmt.Errorf("read Cursor stream response failed: %w", err) } if len(currentDataLines) > 0 || strings.TrimSpace(currentEventType) != "" { @@ -405,7 +405,7 @@ func (p *CursorAgentProvider) ChatStreamWithState(ctx context.Context, state jso if !completedExplicitly { if !receivedAssistantText && !receivedResultText { - callback(ai.StreamChunk{Error: "未收到任何有效响应内容,请检查 Cursor 配置或模型权限", Done: true}) + callback(ai.StreamChunk{Error: "No valid response content was received. Check the Cursor configuration or model access.", Done: true}) return marshalCursorSessionState(sessionState) } callback(ai.StreamChunk{Done: true}) @@ -427,7 +427,7 @@ func (p *CursorAgentProvider) createAgent(ctx context.Context, req ai.ChatReques agentID := strings.TrimSpace(responseBody.Agent.ID) runID := strings.TrimSpace(responseBody.Run.ID) if agentID == "" || runID == "" { - return "", "", fmt.Errorf("Cursor 创建 agent 成功,但未返回有效的 agentId/runId") + return "", "", fmt.Errorf("Cursor created an agent but returned no valid agentId/runId") } return agentID, runID, nil } @@ -452,10 +452,10 @@ func buildCursorCreateAgentRequest(req ai.ChatRequest, model string) (cursorCrea func buildCursorPrompt(messages []ai.Message) (string, error) { prompt := strings.TrimSpace(buildPrompt(messages)) if prompt == "" && requestMessagesContainImages(messages) { - return "请结合这些图片继续分析并回答。", nil + return providerImageFallbackPrompt(""), nil } if prompt == "" { - return "", fmt.Errorf("请求内容不能为空") + return "", fmt.Errorf("request content is required") } return prompt, nil } @@ -489,7 +489,7 @@ func buildCursorImageInputs(messages []ai.Message) ([]cursorImageInput, error) { } mimeType, rawBase64, err := ParseDataURI(trimmed) if err != nil { - return nil, fmt.Errorf("解析图片数据失败: %w", err) + return nil, fmt.Errorf("parse image data failed: %w", err) } images = append(images, cursorImageInput{ Data: rawBase64, @@ -498,7 +498,7 @@ func buildCursorImageInputs(messages []ai.Message) ([]cursorImageInput, error) { } } if len(images) > 5 { - return nil, fmt.Errorf("Cursor 最多支持 5 张图片,当前请求包含 %d 张", len(images)) + return nil, fmt.Errorf("Cursor supports at most 5 images per request; got %d", len(images)) } return images, nil } @@ -531,7 +531,7 @@ func (p *CursorAgentProvider) getRun(ctx context.Context, agentID string, runID endpoint := ResolveCursorAPIEndpoint(p.baseURL, fmt.Sprintf("agents/%s/runs/%s", agentID, runID)) httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return nil, fmt.Errorf("创建 Cursor run 查询失败: %w", err) + return nil, fmt.Errorf("create Cursor run request failed: %w", err) } httpReq.Header.Set("Accept", "application/json") httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) @@ -541,18 +541,18 @@ func (p *CursorAgentProvider) getRun(ctx context.Context, agentID string, runID resp, err := p.client.Do(httpReq) if err != nil { - return nil, fmt.Errorf("查询 Cursor run 状态失败: %w", err) + return nil, fmt.Errorf("request Cursor run status failed: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("Cursor run 查询失败 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + return nil, fmt.Errorf("Cursor run request failed (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) } responseBody := cursorRunResponse{} if err := json.NewDecoder(resp.Body).Decode(&responseBody); err != nil { - return nil, fmt.Errorf("解析 Cursor run 响应失败: %w", err) + return nil, fmt.Errorf("parse Cursor run response failed: %w", err) } return &responseBody, nil } @@ -577,7 +577,7 @@ func (p *CursorAgentProvider) createRun(ctx context.Context, agentID string, req } runID := strings.TrimSpace(responseBody.Run.ID) if runID == "" { - return "", fmt.Errorf("Cursor 创建 follow-up run 成功,但未返回有效 runId") + return "", fmt.Errorf("Cursor created a follow-up run but returned no valid runId") } return runID, nil } @@ -589,7 +589,7 @@ func (p *CursorAgentProvider) openRunStream(ctx context.Context, agentID string, httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { logAIUpstreamRequestFinish(requestLog, 0, err) - return nil, fmt.Errorf("创建 Cursor 流式请求失败: %w", err) + return nil, fmt.Errorf("create Cursor stream request failed: %w", err) } httpReq.Header.Set("Authorization", "Bearer "+p.config.APIKey) httpReq.Header.Set("Accept", "text/event-stream") @@ -601,12 +601,12 @@ func (p *CursorAgentProvider) openRunStream(ctx context.Context, agentID string, resp, err := p.client.Do(httpReq) if err != nil { logAIUpstreamRequestFinish(requestLog, 0, err) - return nil, fmt.Errorf("发送 Cursor 流式请求失败: %w", err) + return nil, fmt.Errorf("request Cursor stream failed: %w", err) } if resp.StatusCode != http.StatusOK { defer resp.Body.Close() bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - statusErr := fmt.Errorf("Cursor API 返回错误 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + statusErr := fmt.Errorf("Cursor API returned error (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr) return nil, statusErr } @@ -620,7 +620,7 @@ func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, if body != nil { bodyBytes, err := json.Marshal(body) if err != nil { - return fmt.Errorf("序列化 Cursor 请求失败: %w", err) + return fmt.Errorf("serialize Cursor request failed: %w", err) } requestBody = bytes.NewReader(bodyBytes) } @@ -629,7 +629,7 @@ func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, httpReq, err := http.NewRequestWithContext(ctx, method, endpoint, requestBody) if err != nil { logAIUpstreamRequestFinish(requestLog, 0, err) - return fmt.Errorf("创建 Cursor 请求失败: %w", err) + return fmt.Errorf("create Cursor request failed: %w", err) } if body != nil { @@ -646,13 +646,13 @@ func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, resp, err := p.client.Do(httpReq) if err != nil { logAIUpstreamRequestFinish(requestLog, 0, err) - return fmt.Errorf("发送 Cursor 请求失败: %w", err) + return fmt.Errorf("request Cursor failed: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - statusErr := fmt.Errorf("Cursor API 返回错误 (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) + statusErr := fmt.Errorf("Cursor API returned error (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes))) logAIUpstreamRequestFinish(requestLog, resp.StatusCode, statusErr) return statusErr } @@ -660,7 +660,7 @@ func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, if target != nil { if err := json.NewDecoder(resp.Body).Decode(target); err != nil { logAIUpstreamRequestFinish(requestLog, resp.StatusCode, err) - return fmt.Errorf("解析 Cursor 响应失败: %w", err) + return fmt.Errorf("parse Cursor response failed: %w", err) } } @@ -674,7 +674,7 @@ func parseCursorSessionState(state json.RawMessage) (cursorSessionState, error) } var result cursorSessionState if err := json.Unmarshal(state, &result); err != nil { - return cursorSessionState{}, fmt.Errorf("解析 Cursor 会话状态失败: %w", err) + return cursorSessionState{}, fmt.Errorf("parse Cursor session state failed: %w", err) } return result, nil } @@ -685,7 +685,7 @@ func marshalCursorSessionState(state cursorSessionState) (json.RawMessage, error } bytes, err := json.Marshal(state) if err != nil { - return nil, fmt.Errorf("序列化 Cursor 会话状态失败: %w", err) + return nil, fmt.Errorf("serialize Cursor session state failed: %w", err) } return json.RawMessage(bytes), nil } @@ -711,7 +711,7 @@ func isCursorRunFailureStatus(status string) bool { func cursorRunStatusMessage(status string, result string) string { normalizedStatus := strings.ToUpper(strings.TrimSpace(status)) if text := strings.TrimSpace(result); text != "" { - return fmt.Sprintf("Cursor 运行结束(%s):%s", normalizedStatus, text) + return fmt.Sprintf("Cursor run finished (%s): %s", normalizedStatus, text) } - return fmt.Sprintf("Cursor 运行结束(%s)", normalizedStatus) + return fmt.Sprintf("Cursor run finished (%s)", normalizedStatus) } diff --git a/internal/ai/provider/custom.go b/internal/ai/provider/custom.go index f8f3376..8720b95 100644 --- a/internal/ai/provider/custom.go +++ b/internal/ai/provider/custom.go @@ -24,7 +24,7 @@ func NewCustomProvider(config ai.ProviderConfig) (Provider, error) { apiFormat = "openai" } if strings.TrimSpace(config.BaseURL) == "" && apiFormat != "claude-cli" && apiFormat != "codebuddy-cli" { - return nil, fmt.Errorf("自定义 Provider 必须指定 Base URL") + return nil, fmt.Errorf("custom provider Base URL is required") } var innerProvider Provider diff --git a/internal/ai/provider/provider_cursor_codebuddy_i18n_test.go b/internal/ai/provider/provider_cursor_codebuddy_i18n_test.go new file mode 100644 index 0000000..7943de9 --- /dev/null +++ b/internal/ai/provider/provider_cursor_codebuddy_i18n_test.go @@ -0,0 +1,373 @@ +package provider + +import ( + "errors" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" +) + +func TestCursorAndCodeBuddySourceUseEnglishErrorWrappers(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + signature string + rawMessages []string + requiredTexts []string + }{ + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) Validate() error {", + rawMessages: []string{`"API Key 不能为空"`}, + requiredTexts: []string{ + `"API key is required"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) {", + rawMessages: []string{ + `"序列化 Cursor 会话状态失败: %w"`, + }, + requiredTexts: []string{ + `"serialize Cursor session state failed: %w"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) {", + rawMessages: []string{ + `"Cursor 流式请求失败"`, + `"未收到任何有效响应内容,请检查 Cursor 配置或模型权限"`, + `"读取 Cursor 流式响应失败: %w"`, + }, + requiredTexts: []string{ + `"Cursor stream request failed"`, + `"No valid response content was received. Check the Cursor configuration or model access."`, + `"read Cursor stream response failed: %w"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) createAgent(ctx context.Context, req ai.ChatRequest) (string, string, error) {", + rawMessages: []string{ + `"Cursor 创建 agent 成功,但未返回有效的 agentId/runId"`, + }, + requiredTexts: []string{ + `"Cursor created an agent but returned no valid agentId/runId"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func buildCursorPrompt(messages []ai.Message) (string, error) {", + rawMessages: []string{ + `"请结合这些图片继续分析并回答。"`, + `"请求内容不能为空"`, + }, + requiredTexts: []string{ + `providerImageFallbackPrompt`, + `"request content is required"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func buildCursorImageInputs(messages []ai.Message) ([]cursorImageInput, error) {", + rawMessages: []string{ + `"解析图片数据失败: %w"`, + `"Cursor 最多支持 5 张图片,当前请求包含 %d 张"`, + }, + requiredTexts: []string{ + `"parse image data failed: %w"`, + `"Cursor supports at most 5 images per request; got %d"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) getRun(ctx context.Context, agentID string, runID string) (*cursorRunResponse, error) {", + rawMessages: []string{ + `"创建 Cursor run 查询失败: %w"`, + `"查询 Cursor run 状态失败: %w"`, + `"Cursor run 查询失败 (HTTP %d): %s"`, + `"解析 Cursor run 响应失败: %w"`, + }, + requiredTexts: []string{ + `"create Cursor run request failed: %w"`, + `"request Cursor run status failed: %w"`, + `"Cursor run request failed (HTTP %d): %s"`, + `"parse Cursor run response failed: %w"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) createRun(ctx context.Context, agentID string, req ai.ChatRequest) (string, error) {", + rawMessages: []string{ + `"Cursor 创建 follow-up run 成功,但未返回有效 runId"`, + }, + requiredTexts: []string{ + `"Cursor created a follow-up run but returned no valid runId"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) openRunStream(ctx context.Context, agentID string, runID string) (io.ReadCloser, error) {", + rawMessages: []string{ + `"创建 Cursor 流式请求失败: %w"`, + `"发送 Cursor 流式请求失败: %w"`, + `"Cursor API 返回错误 (HTTP %d): %s"`, + }, + requiredTexts: []string{ + `"create Cursor stream request failed: %w"`, + `"request Cursor stream failed: %w"`, + `"Cursor API returned error (HTTP %d): %s"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func (p *CursorAgentProvider) doJSONRequest(ctx context.Context, method string, endpoint string, body any, target any, accept string) error {", + rawMessages: []string{ + `"序列化 Cursor 请求失败: %w"`, + `"创建 Cursor 请求失败: %w"`, + `"发送 Cursor 请求失败: %w"`, + `"Cursor API 返回错误 (HTTP %d): %s"`, + `"解析 Cursor 响应失败: %w"`, + }, + requiredTexts: []string{ + `"serialize Cursor request failed: %w"`, + `"create Cursor request failed: %w"`, + `"request Cursor failed: %w"`, + `"Cursor API returned error (HTTP %d): %s"`, + `"parse Cursor response failed: %w"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func parseCursorSessionState(state json.RawMessage) (cursorSessionState, error) {", + rawMessages: []string{ + `"解析 Cursor 会话状态失败: %w"`, + }, + requiredTexts: []string{ + `"parse Cursor session state failed: %w"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func marshalCursorSessionState(state cursorSessionState) (json.RawMessage, error) {", + rawMessages: []string{ + `"序列化 Cursor 会话状态失败: %w"`, + }, + requiredTexts: []string{ + `"serialize Cursor session state failed: %w"`, + }, + }, + { + path: "cursor_agent.go", + signature: "func cursorRunStatusMessage(status string, result string) string {", + rawMessages: []string{ + `"Cursor 运行结束(%s):%s"`, + `"Cursor 运行结束(%s)"`, + }, + requiredTexts: []string{ + `"Cursor run finished (%s): %s"`, + `"Cursor run finished (%s)"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func (p *CodeBuddyCLIProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) {", + rawMessages: []string{ + `"CodeBuddy CLI 执行超时`, + `"CodeBuddy CLI 执行失败: %s"`, + `"CodeBuddy CLI 执行失败: %w"`, + }, + requiredTexts: []string{ + `"CodeBuddy CLI timed out`, + `"CodeBuddy CLI execution failed: %s"`, + `"CodeBuddy CLI execution failed: %w"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resumeSessionID string, req ai.ChatRequest, callback func(ai.StreamChunk)) (string, error) {", + rawMessages: []string{ + `"创建 stdout 管道失败: %w"`, + `"启动 CodeBuddy CLI 失败: %w"`, + `"CodeBuddy CLI 鉴权失败: %s"`, + `"CodeBuddy CLI 返回错误: %s"`, + `"CodeBuddy CLI 执行超时`, + `"CodeBuddy CLI 异常退出: %v"`, + `"CodeBuddy CLI 异常退出: %s"`, + }, + requiredTexts: []string{ + `"create stdout pipe failed: %w"`, + `"start CodeBuddy CLI failed: %w"`, + `"CodeBuddy CLI authentication failed: %s"`, + `"CodeBuddy CLI returned an error: %s"`, + `"CodeBuddy CLI timed out`, + `"CodeBuddy CLI exited unexpectedly: %v"`, + `"CodeBuddy CLI exited unexpectedly: %s"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func parseCodeBuddySessionState(state json.RawMessage) (codebuddySessionState, error) {", + rawMessages: []string{ + `"解析 CodeBuddy 会话状态失败: %w"`, + }, + requiredTexts: []string{ + `"parse CodeBuddy session state failed: %w"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func marshalCodeBuddySessionState(sessionID string) (json.RawMessage, error) {", + rawMessages: []string{ + `"序列化 CodeBuddy 会话状态失败: %w"`, + }, + requiredTexts: []string{ + `"serialize CodeBuddy session state failed: %w"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) {", + rawMessages: []string{ + `"未找到 codebuddy 命令,请先安装 CodeBuddy CLI: npm install -g @tencent/codebuddy"`, + }, + requiredTexts: []string{ + `"CodeBuddy CLI command not found. Install it first: npm install -g @tencent/codebuddy"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func resolveCodeBuddyGitBashPath(env []string, goos string, lookPath func(string) (string, error), exists func(string) bool) (string, error) {", + rawMessages: []string{ + `"CodeBuddy CLI 在 Windows 下配置的 CODEBUDDY_CODE_GIT_BASH_PATH 不存在: %s"`, + }, + requiredTexts: []string{ + `"Configured CODEBUDDY_CODE_GIT_BASH_PATH does not exist on Windows: %s"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, string, error) {", + rawMessages: []string{ + `"CodeBuddy CLI 返回错误: %s"`, + }, + requiredTexts: []string{ + `"CodeBuddy CLI returned an error: %s"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func extractCodeBuddyCLIEventError(event cliStreamEvent) (string, bool) {", + rawMessages: []string{ + `"CodeBuddy CLI 返回未知错误"`, + }, + requiredTexts: []string{ + `"CodeBuddy CLI returned an unknown error"`, + }, + }, + { + path: "codebuddy_cli.go", + signature: "func extractCodeBuddyCLISystemRetryError(event cliStreamEvent) (string, bool) {", + rawMessages: []string{ + `"CodeBuddy CLI 鉴权失败 (HTTP %d): %s"`, + `"CodeBuddy CLI 鉴权失败: %s"`, + }, + requiredTexts: []string{ + `"CodeBuddy CLI authentication failed (HTTP %d): %s"`, + `"CodeBuddy CLI authentication failed: %s"`, + }, + }, + } + + for _, check := range checks { + functionSource := providerFunctionSource(t, check.path, check.signature) + for _, rawMessage := range check.rawMessages { + if strings.Contains(functionSource, rawMessage) { + t.Fatalf("%s still contains raw provider text %q", check.signature, rawMessage) + } + } + for _, requiredText := range check.requiredTexts { + if !strings.Contains(functionSource, requiredText) { + t.Fatalf("%s does not reference english wrapper %q", check.signature, requiredText) + } + } + } +} + +func TestCursorAgentProviderUsesEnglishValidationAndFallbackMessages(t *testing.T) { + t.Parallel() + + providerInstance, err := NewCursorAgentProvider(ai.ProviderConfig{}) + if err != nil { + t.Fatalf("create cursor provider failed: %v", err) + } + + err = providerInstance.Validate() + if err == nil { + t.Fatal("expected missing api key error") + } + if got, want := err.Error(), "API key is required"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + + text, err := buildCursorPrompt([]ai.Message{{Role: "user", Images: []string{"data:image/png;base64,aGVsbG8="}}}) + if err != nil { + t.Fatalf("expected image-only prompt fallback, got %v", err) + } + if got, want := text, "Please describe and analyze this image."; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestBuildCursorImageInputsRejectsTooManyImagesInEnglish(t *testing.T) { + t.Parallel() + + images := make([]string, 0, 6) + for i := 0; i < 6; i++ { + images = append(images, "data:image/png;base64,aGVsbG8=") + } + + _, err := buildCursorImageInputs([]ai.Message{{Role: "user", Content: "analyze", Images: images}}) + if err == nil { + t.Fatal("expected too-many-images error") + } + if got, want := err.Error(), "Cursor supports at most 5 images per request; got 6"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestCodeBuddyCLIUsesEnglishInstallHint(t *testing.T) { + t.Parallel() + + _, err := resolveCodeBuddyCLICommand(func(string) (string, error) { + return "", errors.New("not found") + }) + if err == nil { + t.Fatal("expected missing command error") + } + if got, want := err.Error(), "CodeBuddy CLI command not found. Install it first: npm install -g @tencent/codebuddy"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestBuildCodeBuddyCLIResponseFromEventsUsesEnglishErrorWrapper(t *testing.T) { + t.Parallel() + + _, _, err := buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{ + { + Type: "error", + IsError: true, + Result: "token expired", + }, + }) + if err == nil { + t.Fatal("expected codebuddy response error") + } + if got, want := err.Error(), "CodeBuddy CLI returned an error: token expired"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/internal/ai/service/service.go b/internal/ai/service/service.go index 73d415b..1ae72f4 100644 --- a/internal/ai/service/service.go +++ b/internal/ai/service/service.go @@ -811,11 +811,11 @@ func resolveModelsURL(config ai.ProviderConfig) string { } } -func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) { +func newModelsRequest(config ai.ProviderConfig, localizer *i18n.Localizer) (*http.Request, error) { config = normalizeProviderConfig(config) url := resolveModelsURL(config) if strings.TrimSpace(url) == "" { - return nil, fmt.Errorf("当前供应商不支持远端模型列表") + return nil, fmt.Errorf("create request failed: %s", serviceTextFromLocalizer(localizer, "ai_service.backend.error.models_remote_unsupported", nil)) } req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -863,7 +863,7 @@ func newProviderHealthCheckRequest(config ai.ProviderConfig) (*http.Request, err if isMiniMaxAnthropicProvider(config) || isDashScopeBailianAnthropicProvider(config) || isDashScopeCodingPlanAnthropicProvider(config) { return newAnthropicMessagesHealthCheckRequest(config) } - return newModelsRequest(config) + return newModelsRequest(config, nil) } func newAnthropicMessagesHealthCheckRequest(config ai.ProviderConfig) (*http.Request, error) { @@ -998,7 +998,7 @@ func fetchModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]string, case "gemini": return fetchGeminiModels(config, localizer) case "cursor-agent": - return fetchCursorModels(config) + return fetchCursorModels(config, localizer) case "codebuddy-cli": return append([]string(nil), config.Models...), nil default: @@ -1008,7 +1008,7 @@ func fetchModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]string, // fetchOpenAIModels 获取 OpenAI 兼容 API 的模型列表 func fetchOpenAIModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]string, error) { - req, err := newModelsRequest(config) + req, err := newModelsRequest(config, localizer) if err != nil { return nil, localizeModelListRequestCreateError(localizer, err) } @@ -1043,7 +1043,7 @@ func fetchOpenAIModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]s // fetchAnthropicModels 获取 Anthropic API 的模型列表 func fetchAnthropicModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]string, error) { - req, err := newModelsRequest(config) + req, err := newModelsRequest(config, localizer) if err != nil { return nil, localizeModelListRequestCreateError(localizer, err) } @@ -1121,22 +1121,22 @@ func fetchGeminiModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]s return models, nil } -func fetchCursorModels(config ai.ProviderConfig) ([]string, error) { - req, err := newModelsRequest(config) +func fetchCursorModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]string, error) { + req, err := newModelsRequest(config, localizer) if err != nil { - return nil, err + return nil, localizeModelListRequestCreateError(localizer, err) } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("请求模型列表失败: %w", err) + return nil, localizeModelListRequestError(localizer, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body)) + return nil, localizeModelListHTTPStatusError(localizer, resp.StatusCode, body) } var result struct { @@ -1145,7 +1145,7 @@ func fetchCursorModels(config ai.ProviderConfig) ([]string, error) { } `json:"items"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("解析模型列表失败: %w", err) + return nil, localizeModelListParseError(localizer, err) } models := make([]string, 0, len(result.Items)) @@ -1670,7 +1670,7 @@ func (s *Service) storeSessionProviderRuntime(sessionID string, providerKey stri } else { messageBytes, err := json.Marshal(messages) if err != nil { - return fmt.Errorf("序列化会话 Provider 消息失败: %w", err) + return s.serviceError("ai_service.backend.error.session_provider_messages_serialize_failed", nil, err) } sessionData.ProviderMessages = json.RawMessage(messageBytes) } @@ -1771,7 +1771,7 @@ func (s *Service) loadOrCreateSessionFile(sessionID string) (sessionFileData, er } return sessionFileData{ ID: sessionID, - Title: "新的对话", + Title: s.serviceText("ai_chat.panel.session.default_title", nil), UpdatedAt: time.Now().UnixMilli(), Messages: json.RawMessage("[]"), }, nil diff --git a/internal/ai/service/service_cursor_i18n_test.go b/internal/ai/service/service_cursor_i18n_test.go new file mode 100644 index 0000000..fff43e5 --- /dev/null +++ b/internal/ai/service/service_cursor_i18n_test.go @@ -0,0 +1,61 @@ +package aiservice + +import ( + "os" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" + "GoNavi-Wails/shared/i18n" +) + +func TestAIServiceCursorModelListErrorsUseLocalizedText(t *testing.T) { + sourceBytes, err := os.ReadFile("service.go") + if err != nil { + t.Fatalf("read service.go: %v", err) + } + source := string(sourceBytes) + + functionSource := aiServiceFunctionSource(t, source, "func fetchCursorModels(config ai.ProviderConfig, localizer *i18n.Localizer) ([]string, error) {") + for _, rawMessage := range []string{ + `fmt.Errorf("请求模型列表失败: %w", err)`, + `fmt.Errorf("获取模型列表失败 (HTTP %d): %s", resp.StatusCode, string(body))`, + `fmt.Errorf("解析模型列表失败: %w", err)`, + } { + if strings.Contains(functionSource, rawMessage) { + t.Fatalf("fetchCursorModels still contains raw cursor model-list text %q", rawMessage) + } + } + for _, symbol := range []string{ + "localizeModelListRequestCreateError", + "localizeModelListRequestError", + "localizeModelListHTTPStatusError", + "localizeModelListParseError", + } { + if !strings.Contains(functionSource, symbol) { + t.Fatalf("fetchCursorModels does not reference cursor model-list localization symbol %q", symbol) + } + } +} + +func TestFetchCursorModelsUsesEnglishCreateRequestError(t *testing.T) { + localizer, err := i18n.NewLocalizer(i18n.LanguageEnUS) + if err != nil { + t.Fatalf("new localizer: %v", err) + } + + _, err = fetchCursorModels(ai.ProviderConfig{ + Type: "custom", + APIFormat: "cursor-agent", + BaseURL: "://bad", + }, localizer) + if err == nil { + t.Fatal("expected fetchCursorModels to fail") + } + if !strings.HasPrefix(err.Error(), "Failed to create model list request: ") { + t.Fatalf("expected English cursor model-list wrapper, got %q", err.Error()) + } + if strings.Contains(err.Error(), "创建请求失败") { + t.Fatalf("expected no raw Chinese cursor model-list wrapper, got %q", err.Error()) + } +} diff --git a/internal/ai/service/service_i18n_test.go b/internal/ai/service/service_i18n_test.go index 56ad03a..9cddac6 100644 --- a/internal/ai/service/service_i18n_test.go +++ b/internal/ai/service/service_i18n_test.go @@ -157,7 +157,7 @@ func TestAIServiceHealthCheckRequestErrorsUseLocalizedText(t *testing.T) { rawMessages: []string{`"创建请求失败: "`}, requiredTexts: []string{`"create request failed: "`}, }, - "func newModelsRequest(config ai.ProviderConfig) (*http.Request, error) {": { + "func newModelsRequest(config ai.ProviderConfig, localizer *i18n.Localizer) (*http.Request, error) {": { rawMessages: []string{`fmt.Errorf("创建请求失败: %w", err)`}, requiredTexts: []string{`fmt.Errorf("create request failed: %w", err)`}, }, diff --git a/internal/ai/service/service_session_i18n_test.go b/internal/ai/service/service_session_i18n_test.go new file mode 100644 index 0000000..13775e3 --- /dev/null +++ b/internal/ai/service/service_session_i18n_test.go @@ -0,0 +1,116 @@ +package aiservice + +import ( + "os" + "strings" + "testing" + + "GoNavi-Wails/internal/ai" + "GoNavi-Wails/shared/i18n" +) + +func TestAIServiceAdditionalSessionAndModelMessagesUseLocalizedText(t *testing.T) { + sourceBytes, err := os.ReadFile("service.go") + if err != nil { + t.Fatalf("read service.go: %v", err) + } + source := string(sourceBytes) + + checks := map[string]struct { + rawMessages []string + keys []string + }{ + "func newModelsRequest(config ai.ProviderConfig, localizer *i18n.Localizer) (*http.Request, error) {": { + rawMessages: []string{ + `fmt.Errorf("当前供应商不支持远端模型列表")`, + }, + keys: []string{ + "ai_service.backend.error.models_remote_unsupported", + }, + }, + "func (s *Service) storeSessionProviderRuntime(sessionID string, providerKey string, state json.RawMessage, messages []ai.Message) error {": { + rawMessages: []string{ + `fmt.Errorf("序列化会话 Provider 消息失败: %w", err)`, + }, + keys: []string{ + "ai_service.backend.error.session_provider_messages_serialize_failed", + }, + }, + "func (s *Service) loadOrCreateSessionFile(sessionID string) (sessionFileData, error) {": { + rawMessages: []string{ + `Title: "新的对话"`, + }, + keys: []string{ + "ai_chat.panel.session.default_title", + }, + }, + } + + for signature, check := range checks { + functionSource := aiServiceFunctionSource(t, source, signature) + for _, rawMessage := range check.rawMessages { + if strings.Contains(functionSource, rawMessage) { + t.Fatalf("%s still contains raw AI service text %q", signature, rawMessage) + } + } + for _, key := range check.keys { + if !strings.Contains(functionSource, key) { + t.Fatalf("%s does not reference AI service i18n key %q", signature, key) + } + } + } +} + +func TestAIServiceAdditionalSessionAndModelCatalogKeysExist(t *testing.T) { + catalogs, err := i18n.LoadCatalogs() + if err != nil { + t.Fatalf("LoadCatalogs() error = %v", err) + } + + keys := []string{ + "ai_service.backend.error.models_remote_unsupported", + "ai_service.backend.error.session_provider_messages_serialize_failed", + "ai_chat.panel.session.default_title", + } + + for _, language := range i18n.SupportedLanguages() { + catalog := catalogs[language] + for _, key := range keys { + if strings.TrimSpace(catalog[key]) == "" { + t.Fatalf("%s catalog missing AI service key %q", language, key) + } + } + } +} + +func TestAIServiceNewConversationTitleUsesCurrentLanguage(t *testing.T) { + service := NewServiceWithSecretStore(nil) + service.AISetLanguage("en-US") + service.configDir = t.TempDir() + + sessionData, err := service.loadOrCreateSessionFile("session-1") + if err != nil { + t.Fatalf("loadOrCreateSessionFile: %v", err) + } + if got, want := sessionData.Title, "New chat"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} + +func TestNewModelsRequestUsesLocalizedUnsupportedRemoteListMessage(t *testing.T) { + localizer, err := i18n.NewLocalizer(i18n.LanguageEnUS) + if err != nil { + t.Fatalf("new localizer: %v", err) + } + + _, err = newModelsRequest(ai.ProviderConfig{ + Type: "custom", + APIFormat: "codebuddy-cli", + }, localizer) + if err == nil { + t.Fatal("expected unsupported remote model list error") + } + if got, want := err.Error(), "create request failed: Remote model listing is not supported for the current provider"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } +} diff --git a/internal/ai/service/service_test.go b/internal/ai/service/service_test.go index 0d3be50..0ecf430 100644 --- a/internal/ai/service/service_test.go +++ b/internal/ai/service/service_test.go @@ -62,7 +62,7 @@ func TestNewModelsRequest_StripsChatCompletionsSuffixForOpenAICompatibleProvider Type: "openai", BaseURL: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", APIKey: "sk-test", - }) + }, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/app/methods_db.go b/internal/app/methods_db.go index a26ca0e..c748fb7 100644 --- a/internal/app/methods_db.go +++ b/internal/app/methods_db.go @@ -122,7 +122,7 @@ func (a *App) TestConnection(config connection.ConnectionConfig) connection.Quer if dbInst != nil { if closeErr := dbInst.Close(); closeErr != nil { logger.Error(closeErr, "TestConnection 释放临时连接失败:耗时=%s %s", time.Since(started).Round(time.Millisecond), formatConnSummary(testConfig)) - return connection.QueryResult{Success: false, Message: fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)} + return connection.QueryResult{Success: false, Message: a.appText("db.backend.error.test_connection_close_failed", map[string]any{"detail": closeErr.Error()})} } } @@ -1908,6 +1908,7 @@ func buildFallbackColumnCommentStatement(dbType string, schemaName string, table func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, tableName string) connection.QueryResult { runConfig := normalizeRunConfig(config, dbName) + text := a.appText dbInst, err := a.getDatabase(runConfig) if err != nil { @@ -1932,11 +1933,11 @@ func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, ta return connection.QueryResult{Success: false, Message: err.Error()} } if len(columns) == 0 && resolveDDLDBType(config) == "oracle" { - if inferred, inferErr := inferOracleColumnsFromDictionary(dbInst, schemaName, pureTableName); inferErr == nil && len(inferred) > 0 { + if inferred, inferErr := inferOracleColumnsFromDictionary(dbInst, schemaName, pureTableName, text); inferErr == nil && len(inferred) > 0 { columns = inferred } if len(columns) == 0 { - if inferred, inferErr := inferOracleColumnsFromEmptySelect(dbInst, schemaName, pureTableName); inferErr == nil && len(inferred) > 0 { + if inferred, inferErr := inferOracleColumnsFromEmptySelect(dbInst, schemaName, pureTableName, text); inferErr == nil && len(inferred) > 0 { columns = inferred } } @@ -1945,7 +1946,10 @@ func (a *App) DBGetColumns(config connection.ConnectionConfig, dbName string, ta return connection.QueryResult{Success: true, Data: ensureNonNilSlice(columns)} } -func inferOracleColumnsFromDictionary(dbInst db.Database, schemaName string, tableName string) ([]connection.ColumnDefinition, error) { +func inferOracleColumnsFromDictionary(dbInst db.Database, schemaName string, tableName string, text func(string, map[string]any) string) ([]connection.ColumnDefinition, error) { + if text == nil { + text = defaultDBBackendText + } var lastErr error for _, candidate := range appOracleMetadataNamePairs(schemaName, tableName) { data, _, err := dbInst.Query(buildAppOracleColumnsQuery(candidate.schema, candidate.table)) @@ -1961,7 +1965,7 @@ func inferOracleColumnsFromDictionary(dbInst db.Database, schemaName string, tab if lastErr != nil { return nil, lastErr } - return nil, fmt.Errorf("未获取到字段定义") + return nil, fmt.Errorf("%s", text("db.backend.error.column_definitions_missing", nil)) } type appOracleMetadataNamePair struct { @@ -2196,10 +2200,13 @@ func escapeAppOracleMetadataLiteral(text string) string { return strings.ReplaceAll(strings.TrimSpace(text), "'", "''") } -func inferOracleColumnsFromEmptySelect(dbInst db.Database, schemaName string, tableName string) ([]connection.ColumnDefinition, error) { +func inferOracleColumnsFromEmptySelect(dbInst db.Database, schemaName string, tableName string, text func(string, map[string]any) string) ([]connection.ColumnDefinition, error) { + if text == nil { + text = defaultDBBackendText + } table := strings.TrimSpace(tableName) if table == "" { - return nil, fmt.Errorf("表名不能为空") + return nil, fmt.Errorf("%s", text("db.backend.error.table_name_required", nil)) } query := "SELECT * FROM " + quoteOracleMetadataTableRef(schemaName, table) + " WHERE 1 = 0" @@ -2208,7 +2215,7 @@ func inferOracleColumnsFromEmptySelect(dbInst db.Database, schemaName string, ta return nil, err } if len(fields) == 0 { - return nil, fmt.Errorf("未获取到字段定义") + return nil, fmt.Errorf("%s", text("db.backend.error.column_definitions_missing", nil)) } columns := make([]connection.ColumnDefinition, 0, len(fields)) @@ -2226,7 +2233,7 @@ func inferOracleColumnsFromEmptySelect(dbInst db.Database, schemaName string, ta }) } if len(columns) == 0 { - return nil, fmt.Errorf("未获取到字段定义") + return nil, fmt.Errorf("%s", text("db.backend.error.column_definitions_missing", nil)) } return columns, nil } diff --git a/internal/app/methods_db_conn_test.go b/internal/app/methods_db_conn_test.go index daa51f1..0e5cc63 100644 --- a/internal/app/methods_db_conn_test.go +++ b/internal/app/methods_db_conn_test.go @@ -10,8 +10,9 @@ import ( ) type releaseRecordingDB struct { - closed int - connect func(config connection.ConnectionConfig) error + closed int + connect func(config connection.ConnectionConfig) error + closeErr error } func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { @@ -22,7 +23,7 @@ func (f *releaseRecordingDB) Connect(config connection.ConnectionConfig) error { } func (f *releaseRecordingDB) Close() error { f.closed++ - return nil + return f.closeErr } func (f *releaseRecordingDB) Ping() error { return nil } func (f *releaseRecordingDB) Query(query string) ([]map[string]interface{}, []string, error) { @@ -266,6 +267,49 @@ func TestTestConnectionUsesIsolatedConnectionAndClosesIt(t *testing.T) { } } +func TestTestConnectionReturnsLocalizedCloseFailure(t *testing.T) { + originalNewDatabaseFunc := newDatabaseFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + proxySnapshot := currentGlobalProxyConfig() + defer func() { + newDatabaseFunc = originalNewDatabaseFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + }() + if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + testDB := &releaseRecordingDB{closeErr: errors.New("close failed")} + newDatabaseFunc = func(dbType string) (db.Database, error) { + return testDB, nil + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + app := NewApp() + result := app.TestConnection(connection.ConnectionConfig{ + Type: "mysql", + Host: "127.0.0.1", + Port: 3306, + User: "root", + Database: "app", + }) + + if result.Success { + t.Fatalf("expected localized close failure, got success with %q", result.Message) + } + if want := app.appText("db.backend.error.test_connection_close_failed", map[string]any{"detail": "close failed"}); result.Message != want { + t.Fatalf("expected localized close failure message %q, got %q", want, result.Message) + } + if testDB.closed != 1 { + t.Fatalf("expected isolated test connection to be closed once, got %d", testDB.closed) + } +} + func TestGetDatabaseReleasesSameInstanceCacheAndRetriesOnMaxUserConnections(t *testing.T) { originalNewDatabaseFunc := newDatabaseFunc originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc diff --git a/internal/app/methods_db_i18n_test.go b/internal/app/methods_db_i18n_test.go index bc8bfd4..cae8c07 100644 --- a/internal/app/methods_db_i18n_test.go +++ b/internal/app/methods_db_i18n_test.go @@ -149,8 +149,14 @@ func TestMethodsDBConnectionAndMongoMessagesUseLocalizedText(t *testing.T) { keys: []string{"db.backend.message.release_success"}, }, "func (a *App) TestConnection": { - rawMessages: []string{`Message: "连接成功"`}, - keys: []string{"db.backend.message.connect_success"}, + rawMessages: []string{ + `Message: "连接成功"`, + `fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)`, + }, + keys: []string{ + "db.backend.message.connect_success", + "db.backend.error.test_connection_close_failed", + }, }, "func (a *App) MongoDiscoverMembers": { rawMessages: []string{ @@ -280,6 +286,7 @@ func TestMethodsDBConnectionAndMongoCatalogKeysExist(t *testing.T) { "db.backend.error.clickhouse_address_required", "db.backend.error.mongo_member_discovery_unsupported", "db.backend.message.connect_success", + "db.backend.error.test_connection_close_failed", "db.backend.message.release_success", "db.backend.message.mongo_members_discovered", "db.backend.error.schema_name_required", diff --git a/internal/app/methods_db_oracle_i18n_test.go b/internal/app/methods_db_oracle_i18n_test.go new file mode 100644 index 0000000..87f8ff8 --- /dev/null +++ b/internal/app/methods_db_oracle_i18n_test.go @@ -0,0 +1,141 @@ +package app + +import ( + "os" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/shared/i18n" +) + +type fakeOracleMetadataDB struct { + rows []map[string]interface{} + fields []string + err error +} + +func (db *fakeOracleMetadataDB) Connect(config connection.ConnectionConfig) error { return nil } +func (db *fakeOracleMetadataDB) Close() error { return nil } +func (db *fakeOracleMetadataDB) Ping() error { return nil } +func (db *fakeOracleMetadataDB) Query(query string) ([]map[string]interface{}, []string, error) { + return db.rows, db.fields, db.err +} +func (db *fakeOracleMetadataDB) Exec(query string) (int64, error) { return 0, nil } +func (db *fakeOracleMetadataDB) GetDatabases() ([]string, error) { return nil, nil } +func (db *fakeOracleMetadataDB) GetTables(dbName string) ([]string, error) { + return nil, nil +} +func (db *fakeOracleMetadataDB) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} +func (db *fakeOracleMetadataDB) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} +func (db *fakeOracleMetadataDB) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (db *fakeOracleMetadataDB) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (db *fakeOracleMetadataDB) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (db *fakeOracleMetadataDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} + +func TestMethodsDBOracleMetadataMessagesUseLocalizedText(t *testing.T) { + sourceBytes, err := os.ReadFile("methods_db.go") + if err != nil { + t.Fatalf("read methods_db.go: %v", err) + } + source := string(sourceBytes) + + checks := map[string]struct { + rawMessages []string + keys []string + }{ + "func inferOracleColumnsFromDictionary": { + rawMessages: []string{ + `fmt.Errorf("未获取到字段定义")`, + }, + keys: []string{ + "db.backend.error.column_definitions_missing", + }, + }, + "func inferOracleColumnsFromEmptySelect": { + rawMessages: []string{ + `fmt.Errorf("表名不能为空")`, + `fmt.Errorf("未获取到字段定义")`, + }, + keys: []string{ + "db.backend.error.table_name_required", + "db.backend.error.column_definitions_missing", + }, + }, + } + + for signature, check := range checks { + functionSource := methodsDBFunctionSource(t, source, signature) + for _, rawMessage := range check.rawMessages { + if strings.Contains(functionSource, rawMessage) { + t.Fatalf("%s still contains raw Oracle metadata text %q", signature, rawMessage) + } + } + for _, key := range check.keys { + if !strings.Contains(functionSource, key) { + t.Fatalf("%s does not reference Oracle metadata i18n key %q", signature, key) + } + } + } +} + +func TestMethodsDBOracleMetadataCatalogKeysExist(t *testing.T) { + catalogs, err := i18n.LoadCatalogs() + if err != nil { + t.Fatalf("LoadCatalogs() error = %v", err) + } + + for _, language := range i18n.SupportedLanguages() { + if strings.TrimSpace(catalogs[language]["db.backend.error.column_definitions_missing"]) == "" { + t.Fatalf("%s catalog missing Oracle metadata key %q", language, "db.backend.error.column_definitions_missing") + } + } +} + +func TestMethodsDBOracleMetadataUsesEnglishMessages(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + app.SetLanguage(string(i18n.LanguageEnUS)) + + t.Run("dictionary fallback missing columns", func(t *testing.T) { + _, err := inferOracleColumnsFromDictionary(&fakeOracleMetadataDB{}, "APP", "ORDERS", app.appText) + if err == nil { + t.Fatal("expected inferOracleColumnsFromDictionary to fail") + } + if got, want := err.Error(), "No column definitions were returned"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + + t.Run("empty select requires table name", func(t *testing.T) { + _, err := inferOracleColumnsFromEmptySelect(&fakeOracleMetadataDB{}, "APP", " ", app.appText) + if err == nil { + t.Fatal("expected inferOracleColumnsFromEmptySelect to fail") + } + if got, want := err.Error(), "Table name is required"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + + t.Run("empty select missing fields", func(t *testing.T) { + _, err := inferOracleColumnsFromEmptySelect(&fakeOracleMetadataDB{}, "APP", "ORDERS", app.appText) + if err == nil { + t.Fatal("expected inferOracleColumnsFromEmptySelect to fail") + } + if got, want := err.Error(), "No column definitions were returned"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) +} diff --git a/internal/app/methods_explain.go b/internal/app/methods_explain.go index d335e8b..dfab809 100644 --- a/internal/app/methods_explain.go +++ b/internal/app/methods_explain.go @@ -10,6 +10,7 @@ import ( "GoNavi-Wails/internal/db" "GoNavi-Wails/internal/logger" "GoNavi-Wails/internal/utils" + "GoNavi-Wails/shared/i18n" ) // SQL 诊断工作台后端入口。 @@ -51,6 +52,14 @@ var explainSupportedDBTypes = map[string]bool{ // 需要给足时间避免大查询超时。 const explainStatementTimeoutFloor = 5 * time.Minute +func defaultExplainBackendText(key string, params map[string]any) string { + localizer, err := i18n.NewLocalizer(i18n.LanguageZhCN) + if err != nil { + return key + } + return localizer.T(key, params) +} + // DiagnoseQuery 是 SQL 诊断工作台对外暴露的入口。 // 输入用户 SQL(仅允许 SELECT/WITH),返回执行计划归一化结果。 // PR1 仅返回 ExplainResult;索引建议(Suggestions)在 PR2 规则引擎接入后填充。 @@ -95,6 +104,7 @@ func (a *App) DiagnoseQuery(config connection.ConnectionConfig, dbName, query st // 1. 若 dbInst 实现 ExplainExecer(driver-agent 在 PR2 接入),优先用驱动原生实现 // 2. 否则走 app 层 fallback:buildExplainQuery 构造 EXPLAIN 语句,通过 QueryMulti 执行 func (a *App) executeExplain(dbInst db.Database, config connection.ConnectionConfig, dbType, query string) (connection.ExplainResult, error) { + text := a.appText ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -109,23 +119,23 @@ func (a *App) executeExplain(dbInst db.Database, config connection.ConnectionCon logger.Infof("DiagnoseQuery 走 ExplainExecer 路径:type=%s", dbType) raw, format, err := explainer.Explain(ctx, query) if err != nil { - return connection.ExplainResult{}, fmt.Errorf("驱动 EXPLAIN 执行失败:%w", err) + return connection.ExplainResult{}, fmt.Errorf("%s", text("sql_analysis.backend.error.driver_explain_failed", map[string]any{"detail": err.Error()})) } - return parseExplainRaw(dbType, query, raw, format) + return parseExplainRawWithText(dbType, query, raw, format, text) } // Fallback:app 层构造 EXPLAIN 语句 - wrappedSQL, postQueries, preferFormat, cleanupQueries, err := buildExplainQuery(dbType, query) + wrappedSQL, postQueries, preferFormat, cleanupQueries, err := buildExplainQueryWithText(dbType, query, text) if err != nil { return connection.ExplainResult{}, err } defer runExplainCleanup(dbInst, cleanupQueries) - raw, actualFormat, execErr := executeExplainStatements(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat) + raw, actualFormat, execErr := executeExplainStatementsWithText(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat, text) if execErr != nil { - return connection.ExplainResult{}, fmt.Errorf("执行 EXPLAIN 失败:%w", execErr) + return connection.ExplainResult{}, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_execution_failed", map[string]any{"detail": execErr.Error()})) } - return parseExplainRaw(dbType, query, raw, actualFormat) + return parseExplainRawWithText(dbType, query, raw, actualFormat, text) } // runExplainCleanup 执行清理语句(如 Oracle DELETE FROM plan_table),失败仅记日志不阻塞主流程。 @@ -144,6 +154,10 @@ func runExplainCleanup(dbInst db.Database, cleanupQueries []string) { // executeExplainStatements 执行 EXPLAIN 主语句和后置查询(Oracle 的 DBMS_XPLAN.DISPLAY)。 // 返回拼接后的原文 + 实际格式(可能与 preferFormat 不同,比如 MySQL 5.7 不支持 FORMAT=JSON 时降级)。 func executeExplainStatements(ctx context.Context, dbInst db.Database, dbType, wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) { + return executeExplainStatementsWithText(ctx, dbInst, dbType, wrappedSQL, postQueries, preferFormat, defaultExplainBackendText) +} + +func executeExplainStatementsWithText(ctx context.Context, dbInst db.Database, dbType, wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, text func(string, map[string]any) string) (string, connection.ExplainFormat, error) { statements := []string{wrappedSQL} statements = append(statements, postQueries...) fullSQL := strings.Join(statements, ";\n") @@ -154,21 +168,21 @@ func executeExplainStatements(ctx context.Context, dbInst db.Database, dbType, w if err != nil { return "", preferFormat, err } - return collectExplainRaw(results, preferFormat) + return collectExplainRawWithText(results, preferFormat, text) } if multi, ok := dbInst.(db.MultiResultQuerierContext); ok { results, err := multi.QueryMultiContext(ctx, fullSQL) if err != nil { return "", preferFormat, err } - return collectExplainRaw(results, preferFormat) + return collectExplainRawWithText(results, preferFormat, text) } if multi, ok := dbInst.(db.MultiResultQuerier); ok { results, err := multi.QueryMulti(fullSQL) if err != nil { return "", preferFormat, err } - return collectExplainRaw(results, preferFormat) + return collectExplainRawWithText(results, preferFormat, text) } // 单结果 fallback:只执行第一条 EXPLAIN,忽略 postQueries(不适合 Oracle/SQLServer) @@ -176,21 +190,28 @@ func executeExplainStatements(ctx context.Context, dbInst db.Database, dbType, w if err != nil { return "", preferFormat, err } - return collectExplainRaw([]connection.ResultSetData{{Rows: data}}, preferFormat) + return collectExplainRawWithText([]connection.ResultSetData{{Rows: data}}, preferFormat, text) } // collectExplainRaw 把多个结果集合并为单个原文,并探测实际格式。 // MySQL FORMAT=JSON 返回 1 行 1 列包含完整 JSON 文本;表格模式返回多行多列。 func collectExplainRaw(results []connection.ResultSetData, preferFormat connection.ExplainFormat) (string, connection.ExplainFormat, error) { + return collectExplainRawWithText(results, preferFormat, defaultExplainBackendText) +} + +func collectExplainRawWithText(results []connection.ResultSetData, preferFormat connection.ExplainFormat, text func(string, map[string]any) string) (string, connection.ExplainFormat, error) { + if text == nil { + text = defaultExplainBackendText + } if len(results) == 0 { - return "", preferFormat, fmt.Errorf("EXPLAIN 未返回结果") + return "", preferFormat, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_result_missing", nil)) } // 大多数方言只有 1 个结果集;Oracle 有 2 个(EXPLAIN PLAN 影响 + DBMS_XPLAN.DISPLAY 查询) // 取最后一个非空结果集作为 EXPLAIN 输出(DISPLAY 在 post 查询中) last := pickLastNonEmptyResult(results) if last == nil { - return "", preferFormat, fmt.Errorf("EXPLAIN 返回空结果集") + return "", preferFormat, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_result_empty", nil)) } // 单列单行 + 值是 JSON/XML 字符串 → 直接当原文 @@ -254,6 +275,13 @@ func detectExplainFormat(text string, preferFormat connection.ExplainFormat) con // 每方言在 explain_parse_.go 中实现 parseXxxExplain,这里按 dbType 分发。 // 未实现的方言返回原文 + 警告,保证主流程不阻塞。 func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFormat) (connection.ExplainResult, error) { + return parseExplainRawWithText(dbType, sourceSQL, raw, format, defaultExplainBackendText) +} + +func parseExplainRawWithText(dbType, sourceSQL, raw string, format connection.ExplainFormat, text func(string, map[string]any) string) (connection.ExplainResult, error) { + if text == nil { + text = defaultExplainBackendText + } switch dbType { case "mysql", "mariadb", "diros", "starrocks", "oceanbase": return parseMySQLExplain(dbType, sourceSQL, raw, format) @@ -268,7 +296,7 @@ func parseExplainRaw(dbType, sourceSQL, raw string, format connection.ExplainFor case "sqlserver": return parseSQLServerExplain(sourceSQL, raw, format) default: - return connection.ExplainResult{}, fmt.Errorf("不支持的 EXPLAIN 方言:%s", dbType) + return connection.ExplainResult{}, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_dialect_unsupported", map[string]any{"dbType": dbType})) } } @@ -295,6 +323,13 @@ func getDiagnoseTimeout(config connection.ConnectionConfig) time.Duration { // // 参考现有风格:buildListViewQueries (methods_file.go:3102) 的 switch-case 模式。 func buildExplainQuery(dbType, query string) (wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, cleanupQueries []string, err error) { + return buildExplainQueryWithText(dbType, query, defaultExplainBackendText) +} + +func buildExplainQueryWithText(dbType, query string, text func(string, map[string]any) string) (wrappedSQL string, postQueries []string, preferFormat connection.ExplainFormat, cleanupQueries []string, err error) { + if text == nil { + text = defaultExplainBackendText + } sql := strings.TrimRight(strings.TrimSpace(query), ";") switch dbType { case "mysql", "mariadb", "oceanbase": @@ -329,6 +364,6 @@ func buildExplainQuery(dbType, query string) (wrappedSQL string, postQueries []s post := []string{"SET SHOWPLAN_XML OFF;"} return wrapped, post, connection.ExplainFormatXML, nil, nil default: - return "", nil, "", nil, fmt.Errorf("方言 %s 的 EXPLAIN 构造未实现", dbType) + return "", nil, "", nil, fmt.Errorf("%s", text("sql_analysis.backend.error.explain_query_not_implemented", map[string]any{"dbType": dbType})) } } diff --git a/internal/app/methods_explain_backend_i18n_test.go b/internal/app/methods_explain_backend_i18n_test.go new file mode 100644 index 0000000..33e99a4 --- /dev/null +++ b/internal/app/methods_explain_backend_i18n_test.go @@ -0,0 +1,174 @@ +package app + +import ( + "context" + "errors" + "os" + "strings" + "testing" + + "GoNavi-Wails/internal/connection" + "GoNavi-Wails/shared/i18n" +) + +type fakeExplainErrorDatabase struct { + explainErr error +} + +func (db *fakeExplainErrorDatabase) Connect(config connection.ConnectionConfig) error { return nil } +func (db *fakeExplainErrorDatabase) Close() error { return nil } +func (db *fakeExplainErrorDatabase) Ping() error { return nil } +func (db *fakeExplainErrorDatabase) Query(query string) ([]map[string]interface{}, []string, error) { + return nil, nil, nil +} +func (db *fakeExplainErrorDatabase) Exec(query string) (int64, error) { return 0, nil } +func (db *fakeExplainErrorDatabase) GetDatabases() ([]string, error) { return nil, nil } +func (db *fakeExplainErrorDatabase) GetTables(dbName string) ([]string, error) { + return nil, nil +} +func (db *fakeExplainErrorDatabase) GetCreateStatement(dbName, tableName string) (string, error) { + return "", nil +} +func (db *fakeExplainErrorDatabase) GetColumns(dbName, tableName string) ([]connection.ColumnDefinition, error) { + return nil, nil +} +func (db *fakeExplainErrorDatabase) GetAllColumns(dbName string) ([]connection.ColumnDefinitionWithTable, error) { + return nil, nil +} +func (db *fakeExplainErrorDatabase) GetIndexes(dbName, tableName string) ([]connection.IndexDefinition, error) { + return nil, nil +} +func (db *fakeExplainErrorDatabase) GetForeignKeys(dbName, tableName string) ([]connection.ForeignKeyDefinition, error) { + return nil, nil +} +func (db *fakeExplainErrorDatabase) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) { + return nil, nil +} +func (db *fakeExplainErrorDatabase) Explain(ctx context.Context, query string) (string, connection.ExplainFormat, error) { + return "", connection.ExplainFormatJSON, db.explainErr +} + +func TestMethodsExplainBackendErrorSourcesUseLocalizedText(t *testing.T) { + sourceBytes, err := os.ReadFile("methods_explain.go") + if err != nil { + t.Fatalf("read methods_explain.go: %v", err) + } + source := string(sourceBytes) + + checks := map[string]struct { + rawMessages []string + keys []string + }{ + "func (a *App) executeExplain": { + rawMessages: []string{ + `fmt.Errorf("驱动 EXPLAIN 执行失败`, + `fmt.Errorf("执行 EXPLAIN 失败`, + }, + keys: []string{ + "sql_analysis.backend.error.driver_explain_failed", + "sql_analysis.backend.error.explain_execution_failed", + }, + }, + "func collectExplainRawWithText": { + rawMessages: []string{ + `fmt.Errorf("未返回 EXPLAIN 结果集")`, + `fmt.Errorf("EXPLAIN 结果集为空")`, + }, + keys: []string{ + "sql_analysis.backend.error.explain_result_missing", + "sql_analysis.backend.error.explain_result_empty", + }, + }, + "func parseExplainRawWithText": { + rawMessages: []string{ + `fmt.Errorf("当前数据源`, + }, + keys: []string{ + "sql_analysis.backend.error.explain_dialect_unsupported", + }, + }, + "func buildExplainQueryWithText": { + rawMessages: []string{ + `fmt.Errorf("当前数据源`, + }, + keys: []string{ + "sql_analysis.backend.error.explain_query_not_implemented", + }, + }, + } + + for signature, check := range checks { + functionSource := methodsExplainFunctionSource(t, source, signature) + for _, rawMessage := range check.rawMessages { + if strings.Contains(functionSource, rawMessage) { + t.Fatalf("%s still contains raw explain backend text %q", signature, rawMessage) + } + } + for _, key := range check.keys { + if !strings.Contains(functionSource, key) { + t.Fatalf("%s does not reference explain backend i18n key %q", signature, key) + } + } + } +} + +func TestMethodsExplainBackendErrorCatalogKeysExist(t *testing.T) { + catalogs, err := i18n.LoadCatalogs() + if err != nil { + t.Fatalf("LoadCatalogs() error = %v", err) + } + + keys := []string{ + "sql_analysis.backend.error.driver_explain_failed", + "sql_analysis.backend.error.explain_execution_failed", + "sql_analysis.backend.error.explain_result_missing", + "sql_analysis.backend.error.explain_result_empty", + "sql_analysis.backend.error.explain_dialect_unsupported", + "sql_analysis.backend.error.explain_query_not_implemented", + } + + for _, language := range i18n.SupportedLanguages() { + catalog := catalogs[language] + for _, key := range keys { + if strings.TrimSpace(catalog[key]) == "" { + t.Fatalf("%s catalog missing explain backend key %q", language, key) + } + } + } +} + +func TestMethodsExplainBackendErrorsUseEnglishMessages(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + app.SetLanguage(string(i18n.LanguageEnUS)) + + t.Run("driver explain failure", func(t *testing.T) { + _, err := app.executeExplain(&fakeExplainErrorDatabase{explainErr: errors.New("driver exploded")}, connection.ConnectionConfig{}, "mysql", "select 1") + if err == nil { + t.Fatal("expected executeExplain to fail") + } + if got, want := err.Error(), "Driver EXPLAIN execution failed: driver exploded"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + + t.Run("missing explain result", func(t *testing.T) { + _, _, err := collectExplainRawWithText(nil, connection.ExplainFormatJSON, app.appText) + if err == nil { + t.Fatal("expected collectExplainRawWithText to fail") + } + if got, want := err.Error(), "No EXPLAIN result set was returned"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) + + t.Run("unsupported explain query generation", func(t *testing.T) { + _, _, _, _, err := buildExplainQueryWithText("redis", "select 1", app.appText) + if err == nil { + t.Fatal("expected buildExplainQueryWithText to fail") + } + if got, want := err.Error(), "EXPLAIN query generation is not implemented for redis"; got != want { + t.Fatalf("expected %q, got %q", want, got) + } + }) +} diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index b8cdbbd..6ad1f06 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -219,25 +219,32 @@ func (r *exportProgressReporter) ForceRunning(current int64, stage string) { r.emit("running", stage, current, "", true) } +func (r *exportProgressReporter) text(key string, params map[string]any) string { + if r == nil || r.app == nil { + return key + } + return r.app.appText(key, params) +} + func (r *exportProgressReporter) Finalizing(current int64) { - stage := "正在完成文件写入" + stageKey := "data_export.progress.stage.finalizing_file_write" if r != nil { switch strings.ToLower(strings.TrimSpace(r.format)) { case "xlsx": - stage = "正在封装并压缩 XLSX 文件" + stageKey = "data_export.progress.stage.finalizing_xlsx_package" case "csv": - stage = "正在完成 CSV 写入" + stageKey = "data_export.progress.stage.finalizing_csv_write" } } - r.emit("finalizing", stage, current, "", true) + r.emit("finalizing", r.text(stageKey, nil), current, "", true) } func (r *exportProgressReporter) Done(current int64) { - r.emit("done", "导出完成", current, "", true) + r.emit("done", r.text("file.backend.message.export_completed", nil), current, "", true) } func (r *exportProgressReporter) Error(current int64, message string) { - r.emit("error", "导出失败", current, message, true) + r.emit("error", r.text("data_export.progress.stage.export_failed", nil), current, message, true) } func resolveExportTotalRowValue(value interface{}) (int64, bool) { @@ -364,7 +371,10 @@ func verifyOptionalDriverAgentReadyForExport(config connection.ConnectionConfig) } if _, err := verifyInstalledOptionalDriverAgentRevision(driverType, executablePath); err != nil { displayName := resolveDriverDisplayName(driverDefinition{Type: driverType}) - return fmt.Errorf("当前导出依赖最新的 %s driver-agent 流式协议;为避免大结果集回退到高内存缓冲模式,请在驱动管理中重装后重试:%w", displayName, err) + return fmt.Errorf("%s", defaultAppText("file.backend.error.export_driver_agent_streaming_required", map[string]any{ + "driver": displayName, + "detail": err.Error(), + })) } return nil } @@ -442,14 +452,21 @@ func buildDatabaseExportDefaultFilename(dbName string, includeData bool) string } func resolveBatchObjectsTargetName(dbName string, objectNames []string) string { + return resolveBatchObjectsTargetNameWithText(dbName, objectNames, nil) +} + +func resolveBatchObjectsTargetNameWithText(dbName string, objectNames []string, text fileBackendTextFunc) string { if len(objectNames) == 1 { return objectNames[0] } safeDbName := strings.TrimSpace(dbName) if safeDbName == "" { - safeDbName = "当前数据库" + safeDbName = fileBackendText(text, "data_export.workbench.target.current_database", nil) } - return fmt.Sprintf("%s · %d 个对象", safeDbName, len(objectNames)) + return fileBackendText(text, "data_export.workbench.target.batch_tables", map[string]any{ + "database": safeDbName, + "count": len(objectNames), + }) } func normalizeSQLDirectoryPath(directoryPath string) (string, error) { @@ -2397,7 +2414,7 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName } reporter := newExportProgressReporter(a, options, tableName, filename) - reporter.Start("正在准备导出") + reporter.Start(a.appText("data_export.progress.stage.preparing_export", nil)) runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) @@ -2413,13 +2430,13 @@ func (a *App) ExportTableWithOptions(config connection.ConnectionConfig, dbName if reporter != nil { reporter.totalRows = totalRows reporter.totalRowsKnown = true - reporter.Start("正在准备导出") + reporter.Start(a.appText("data_export.progress.stage.preparing_export", nil)) } } } if format == "sql" { - reporter.Start("正在导出 SQL 文件") + reporter.Start(a.appText("data_export.progress.stage.exporting_sql_file", nil)) f, err := os.Create(filename) if err != nil { reporter.Error(0, err.Error()) @@ -2505,9 +2522,9 @@ func (a *App) ExportTablesSQLWithOptions( return connection.QueryResult{Success: false, Message: "已取消"} } - reporter := newExportProgressReporter(a, options, resolveBatchObjectsTargetName(dbName, objects), filename) + reporter := newExportProgressReporter(a, options, resolveBatchObjectsTargetNameWithText(dbName, objects, a.appText), filename) if reporter != nil { - reporter.Start("正在准备批量对象导出") + reporter.Start(a.appText("data_export.progress.stage.preparing_batch_tables_export", nil)) } return a.exportTablesSQLToFile(config, dbName, objects, includeSchema, includeData, filename, reporter) } @@ -2574,7 +2591,11 @@ func (a *App) exportTablesSQLToFile( } for index, objectName := range objects { if reporter != nil { - reporter.ForceRunning(int64(index), fmt.Sprintf("正在导出 %s (%d/%d)", objectName, index+1, len(objects))) + reporter.ForceRunning(int64(index), a.appText("data_export.progress.stage.exporting_item_with_progress", map[string]any{ + "name": objectName, + "current": index + 1, + "total": len(objects), + })) } if err := dumpTableSQL(w, dbInst, runConfig, dbName, objectName, includeSchema, includeData, viewLookup); err != nil { if reporter != nil { @@ -2583,7 +2604,11 @@ func (a *App) exportTablesSQLToFile( return connection.QueryResult{Success: false, Message: err.Error()} } if reporter != nil { - reporter.ForceRunning(int64(index+1), fmt.Sprintf("正在导出 %s (%d/%d)", objectName, index+1, len(objects))) + reporter.ForceRunning(int64(index+1), a.appText("data_export.progress.stage.exporting_item_with_progress", map[string]any{ + "name": objectName, + "current": index + 1, + "total": len(objects), + })) } } if err := writeSQLFooter(w, runConfig); err != nil { @@ -2632,11 +2657,11 @@ func (a *App) ExportDatabasesSQLWithOptions( ) connection.QueryResult { normalizedDbNames := normalizeExportNameList(dbNames) if len(normalizedDbNames) == 0 { - return connection.QueryResult{Success: false, Message: "请至少选择一个数据库"} + return connection.QueryResult{Success: false, Message: a.appText("sidebar.message.select_database_required", nil)} } directory, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ - Title: "选择批量导出目录", + Title: a.appText("file.backend.dialog.select_batch_export_directory", nil), DefaultDirectory: normalizeDirectoryDialogPath(""), }) if err != nil || strings.TrimSpace(directory) == "" { @@ -2646,14 +2671,18 @@ func (a *App) ExportDatabasesSQLWithOptions( options = normalizeExportFileOptions("sql", options) options.TotalRowsHint = int64(len(normalizedDbNames)) options.TotalRowsKnown = true - reporter := newExportProgressReporter(a, options, fmt.Sprintf("%d 个数据库", len(normalizedDbNames)), directory) + reporter := newExportProgressReporter(a, options, a.appText("data_export.workbench.target.batch_databases", map[string]any{"count": len(normalizedDbNames)}), directory) if reporter != nil { - reporter.Start("正在准备批量库导出") + reporter.Start(a.appText("data_export.progress.stage.preparing_batch_databases_export", nil)) } for index, name := range normalizedDbNames { if reporter != nil { - reporter.ForceRunning(int64(index), fmt.Sprintf("正在导出 %s (%d/%d)", name, index+1, len(normalizedDbNames))) + reporter.ForceRunning(int64(index), a.appText("data_export.progress.stage.exporting_item_with_progress", map[string]any{ + "name": name, + "current": index + 1, + "total": len(normalizedDbNames), + })) } targetFile := filepath.Join(directory, buildDatabaseExportDefaultFilename(name, includeData)) result := a.exportDatabaseSQLToFile(config, name, includeData, targetFile) @@ -2664,7 +2693,11 @@ func (a *App) ExportDatabasesSQLWithOptions( return result } if reporter != nil { - reporter.ForceRunning(int64(index+1), fmt.Sprintf("正在导出 %s (%d/%d)", name, index+1, len(normalizedDbNames))) + reporter.ForceRunning(int64(index+1), a.appText("data_export.progress.stage.exporting_item_with_progress", map[string]any{ + "name": name, + "current": index + 1, + "total": len(normalizedDbNames), + })) } } @@ -2674,7 +2707,7 @@ func (a *App) ExportDatabasesSQLWithOptions( } return connection.QueryResult{ Success: true, - Message: "导出完成", + Message: a.appText("file.backend.message.export_completed", nil), Data: map[string]interface{}{ "directoryPath": directory, "fileCount": len(normalizedDbNames), @@ -2690,7 +2723,7 @@ func (a *App) exportDatabaseSQLToFile( ) connection.QueryResult { safeDbName := strings.TrimSpace(dbName) if safeDbName == "" { - return connection.QueryResult{Success: false, Message: "数据库名称不能为空"} + return connection.QueryResult{Success: false, Message: a.appText("file.backend.error.database_name_required", nil)} } runConfig := normalizeRunConfig(config, dbName) @@ -3791,7 +3824,7 @@ func (a *App) ExportDataWithOptions(data []map[string]interface{}, columns []str } logger.Infof("ExportData 选定文件:%s", filename) reporter := newExportProgressReporter(a, options, defaultName, filename) - reporter.Start("正在准备导出") + reporter.Start(a.appText("data_export.progress.stage.preparing_export", nil)) f, err := os.Create(filename) if err != nil { @@ -3847,7 +3880,7 @@ func (a *App) ExportQueryWithOptions(config connection.ConnectionConfig, dbName } logger.Infof("ExportQuery 开始:type=%s db=%s format=%s file=%s sql=%q", strings.TrimSpace(config.Type), strings.TrimSpace(dbName), strings.ToLower(strings.TrimSpace(format)), filename, sqlSnippet(query)) reporter := newExportProgressReporter(a, options, defaultName, filename) - reporter.Start("正在准备导出") + reporter.Start(a.appText("data_export.progress.stage.preparing_export", nil)) runConfig := normalizeRunConfig(config, dbName) dbInst, err := a.getDatabase(runConfig) @@ -3947,7 +3980,7 @@ func (c *countingExportConsumer) SetColumns(columns []string) error { } } if c.reporter != nil { - c.reporter.ForceRunning(c.rowCount, "正在写入文件") + c.reporter.ForceRunning(c.rowCount, c.reporter.text("data_export.progress.stage.writing_file", nil)) } return nil } @@ -3960,7 +3993,7 @@ func (c *countingExportConsumer) ConsumeRow(row map[string]interface{}) error { } c.rowCount++ if c.reporter != nil { - c.reporter.Rows(c.rowCount, "正在写入文件") + c.reporter.Rows(c.rowCount, c.reporter.text("data_export.progress.stage.writing_file", nil)) } return nil } @@ -3987,7 +4020,7 @@ func (c *countingExportConsumer) ConsumeRowValues(values []interface{}) error { } c.rowCount++ if c.reporter != nil { - c.reporter.Rows(c.rowCount, "正在写入文件") + c.reporter.Rows(c.rowCount, c.reporter.text("data_export.progress.stage.writing_file", nil)) } return nil } @@ -4561,7 +4594,7 @@ func exportQueryResultToFile(f *os.File, dbInst db.Database, config connection.C } if reporter != nil { - reporter.Start("正在查询数据") + reporter.Start(reporter.text("data_export.progress.stage.querying_data", nil)) } consumer := &countingExportConsumer{delegate: writer, reporter: reporter} streamErr := streamQueryDataForExport(dbInst, config, query, consumer) @@ -4629,7 +4662,7 @@ func writeRowsToFileWithReporter(f *os.File, data []map[string]interface{}, colu return 0, err } if reporter != nil { - reporter.ForceRunning(0, "正在写入文件") + reporter.ForceRunning(0, reporter.text("data_export.progress.stage.writing_file", nil)) } for index, row := range data { if err := writer.ConsumeRow(row); err != nil { @@ -4637,7 +4670,7 @@ func writeRowsToFileWithReporter(f *os.File, data []map[string]interface{}, colu return int64(index), err } if reporter != nil { - reporter.Rows(int64(index+1), "正在写入文件") + reporter.Rows(int64(index+1), reporter.text("data_export.progress.stage.writing_file", nil)) } } if reporter != nil { diff --git a/internal/app/methods_file_export_test.go b/internal/app/methods_file_export_test.go index 2fb3645..7b264d4 100644 --- a/internal/app/methods_file_export_test.go +++ b/internal/app/methods_file_export_test.go @@ -14,6 +14,7 @@ import ( "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/db" + "GoNavi-Wails/shared/i18n" "github.com/xuri/excelize/v2" ) @@ -478,10 +479,13 @@ func TestTryResolveExportTableTotalRows_UsesCountQuery(t *testing.T) { func TestVerifyOptionalDriverAgentReadyForExport_RejectsStaleAgent(t *testing.T) { originalProbe := optionalDriverAgentMetadataProbe originalResolvePath := resolveOptionalDriverAgentExecutablePathFunc + originalLanguage := defaultAppTextLanguage t.Cleanup(func() { optionalDriverAgentMetadataProbe = originalProbe resolveOptionalDriverAgentExecutablePathFunc = originalResolvePath + setDefaultAppLanguage(originalLanguage) }) + setDefaultAppLanguage(i18n.LanguageEnUS) resolveOptionalDriverAgentExecutablePathFunc = func(downloadDir string, driverType string) (string, error) { return "/tmp/oceanbase-driver-agent", nil @@ -497,8 +501,12 @@ func TestVerifyOptionalDriverAgentReadyForExport_RejectsStaleAgent(t *testing.T) if err == nil { t.Fatal("预期旧版 OceanBase driver-agent 被导出前校验拦截") } - if !strings.Contains(err.Error(), "流式协议") { - t.Fatalf("错误信息应说明需要流式协议,got=%q", err.Error()) + expectedDriverName := resolveDriverDisplayName(driverDefinition{Type: "oceanbase"}) + if strings.Contains(err.Error(), "当前导出依赖最新的") { + t.Fatalf("错误信息不应再直接返回中文原文,got=%q", err.Error()) + } + if !strings.Contains(err.Error(), "latest "+expectedDriverName+" driver-agent streaming protocol") { + t.Fatalf("错误信息应说明需要最新的 driver-agent 流式协议,got=%q", err.Error()) } } diff --git a/internal/app/methods_file_i18n_test.go b/internal/app/methods_file_i18n_test.go index 30e49c7..90b2743 100644 --- a/internal/app/methods_file_i18n_test.go +++ b/internal/app/methods_file_i18n_test.go @@ -205,6 +205,37 @@ func TestExternalSQLFileBackendCatalogKeysExist(t *testing.T) { } } +func TestExportDriverAgentGuardUsesLocalizedText(t *testing.T) { + sourceBytes, err := os.ReadFile("methods_file.go") + if err != nil { + t.Fatalf("read methods_file.go: %v", err) + } + source := string(sourceBytes) + + functionSource := methodsFileFunctionSource(t, source, "func verifyOptionalDriverAgentReadyForExport(config connection.ConnectionConfig) error {") + rawLiteral := `fmt.Errorf("当前导出依赖最新的 %s driver-agent 流式协议;为避免大结果集回退到高内存缓冲模式,请在驱动管理中重装后重试:%w", displayName, err)` + if strings.Contains(functionSource, rawLiteral) { + t.Fatalf("verifyOptionalDriverAgentReadyForExport still contains raw export driver-agent guard text %q", rawLiteral) + } + if !strings.Contains(functionSource, "file.backend.error.export_driver_agent_streaming_required") { + t.Fatal("verifyOptionalDriverAgentReadyForExport does not reference export driver-agent streaming i18n key") + } +} + +func TestExportDriverAgentGuardCatalogKeyExists(t *testing.T) { + catalogs, err := i18n.LoadCatalogs() + if err != nil { + t.Fatalf("LoadCatalogs() error = %v", err) + } + + for _, language := range i18n.SupportedLanguages() { + catalog := catalogs[language] + if strings.TrimSpace(catalog["file.backend.error.export_driver_agent_streaming_required"]) == "" { + t.Fatalf("%s catalog missing export driver-agent streaming key", language) + } + } +} + func TestFileSelectorDialogCatalogKeysExist(t *testing.T) { catalogs, err := i18n.LoadCatalogs() if err != nil { @@ -434,11 +465,14 @@ func TestExportBackendMessagesUseLocalizedText(t *testing.T) { "Export %s", "\u5bfc\u51fa\u5b8c\u6210", "Message: \"\u5199\u5165\u5931\u8d25\uff1a", + "\u6b63\u5728\u51c6\u5907\u5bfc\u51fa", + "\u6b63\u5728\u5bfc\u51fa SQL \u6587\u4ef6", }, "func (a *App) exportTablesSQL": { "\u65e0\u6548\u7684\u5bfc\u51fa\u6a21\u5f0f", "Export Tables (SQL)", "\u5bfc\u51fa\u5b8c\u6210", + "\u6b63\u5728\u51c6\u5907\u6279\u91cf\u5bf9\u8c61\u5bfc\u51fa", }, "func (a *App) ExportDatabaseSQL": { "\u6570\u636e\u5e93\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a", @@ -456,6 +490,7 @@ func TestExportBackendMessagesUseLocalizedText(t *testing.T) { "Export Data", "Message: \"\u5199\u5165\u5931\u8d25\uff1a", "\u5bfc\u51fa\u5b8c\u6210", + "\u6b63\u5728\u51c6\u5907\u5bfc\u51fa", }, "func (a *App) ExportQuery": { "\u67e5\u8be2\u8bed\u53e5\u4e0d\u80fd\u4e3a\u7a7a", @@ -463,6 +498,48 @@ func TestExportBackendMessagesUseLocalizedText(t *testing.T) { "\u4ec5\u652f\u6301 SELECT/WITH \u67e5\u8be2\u5bfc\u51fa", "Message: \"\u5199\u5165\u5931\u8d25\uff1a", "\u5bfc\u51fa\u5b8c\u6210", + "\u6b63\u5728\u51c6\u5907\u5bfc\u51fa", + }, + "func (r *exportProgressReporter) Finalizing": { + "\u6b63\u5728\u5b8c\u6210\u6587\u4ef6\u5199\u5165", + "\u6b63\u5728\u5c01\u88c5\u5e76\u538b\u7f29 XLSX \u6587\u4ef6", + "\u6b63\u5728\u5b8c\u6210 CSV \u5199\u5165", + }, + "func (r *exportProgressReporter) Done": { + "\u5bfc\u51fa\u5b8c\u6210", + }, + "func (r *exportProgressReporter) Error": { + "\u5bfc\u51fa\u5931\u8d25", + }, + "func resolveBatchObjectsTargetName": { + "\u5f53\u524d\u6570\u636e\u5e93", + `fmt.Sprintf("%s · %d 个对象", safeDbName, len(objectNames))`, + }, + "func (a *App) ExportDatabasesSQLWithOptions": { + "\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u4e2a\u6570\u636e\u5e93", + "Title: \"\u9009\u62e9\u6279\u91cf\u5bfc\u51fa\u76ee\u5f55\"", + `fmt.Sprintf("%d 个数据库", len(normalizedDbNames))`, + "\u6b63\u5728\u51c6\u5907\u6279\u91cf\u5e93\u5bfc\u51fa", + `fmt.Sprintf("正在导出 %s (%d/%d)", name, index+1, len(normalizedDbNames))`, + "Message: \"\u5bfc\u51fa\u5b8c\u6210\"", + }, + "func (a *App) exportDatabaseSQLToFile": { + "\u6570\u636e\u5e93\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a", + }, + "func (c *countingExportConsumer) SetColumns": { + "\u6b63\u5728\u5199\u5165\u6587\u4ef6", + }, + "func (c *countingExportConsumer) ConsumeRow": { + "\u6b63\u5728\u5199\u5165\u6587\u4ef6", + }, + "func (c *countingExportConsumer) ConsumeRowValues": { + "\u6b63\u5728\u5199\u5165\u6587\u4ef6", + }, + "func exportQueryResultToFile": { + "\u6b63\u5728\u67e5\u8be2\u6570\u636e", + }, + "func writeRowsToFileWithReporter": { + "\u6b63\u5728\u5199\u5165\u6587\u4ef6", }, } @@ -495,9 +572,25 @@ func TestExportBackendCatalogKeysExist(t *testing.T) { "file.backend.error.schema_export_no_objects", "file.backend.error.schema_name_required", "file.backend.error.select_with_query_required", + "file.backend.dialog.select_batch_export_directory", "file.backend.error.write_failed", "file.backend.filter.connection_package", "file.backend.message.export_completed", + "data_export.progress.stage.preparing_export", + "data_export.progress.stage.exporting_sql_file", + "data_export.progress.stage.preparing_batch_tables_export", + "data_export.progress.stage.preparing_batch_databases_export", + "data_export.progress.stage.exporting_item_with_progress", + "data_export.progress.stage.querying_data", + "data_export.progress.stage.writing_file", + "data_export.progress.stage.finalizing_file_write", + "data_export.progress.stage.finalizing_xlsx_package", + "data_export.progress.stage.finalizing_csv_write", + "data_export.progress.stage.export_failed", + "data_export.workbench.target.batch_databases", + "data_export.workbench.target.batch_tables", + "data_export.workbench.target.current_database", + "sidebar.message.select_database_required", } for _, language := range i18n.SupportedLanguages() { catalog := catalogs[language] diff --git a/internal/app/methods_redis.go b/internal/app/methods_redis.go index fe22cbc..6235787 100644 --- a/internal/app/methods_redis.go +++ b/internal/app/methods_redis.go @@ -271,11 +271,11 @@ func (a *App) RedisTestConnection(config connection.ConnectionConfig) connection if client != nil { if closeErr := client.Close(); closeErr != nil { logger.Error(closeErr, "RedisTestConnection 释放临时连接失败:%s", formatRedisConnSummary(config)) - return connection.QueryResult{Success: false, Message: fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)} + return connection.QueryResult{Success: false, Message: a.appText("redis.backend.error.test_connection_close_failed", map[string]any{"detail": closeErr.Error()})} } } logger.Infof("RedisTestConnection 连接成功:%s", formatRedisConnSummary(config)) - return connection.QueryResult{Success: true, Message: "连接成功"} + return connection.QueryResult{Success: true, Message: a.appText("redis.backend.message.connect_success", nil)} } // RedisScanKeys scans keys matching a pattern diff --git a/internal/app/methods_redis_i18n_test.go b/internal/app/methods_redis_i18n_test.go index 7ad0834..3ea0acf 100644 --- a/internal/app/methods_redis_i18n_test.go +++ b/internal/app/methods_redis_i18n_test.go @@ -40,6 +40,16 @@ func TestRedisBackendOperationMessagesUseLocalizedText(t *testing.T) { rawMessages: []string{`Message: "连接成功"`}, keys: []string{"redis.backend.message.connect_success"}, }, + "func (a *App) RedisTestConnection": { + rawMessages: []string{ + `Message: "连接成功"`, + `fmt.Sprintf("连接成功但释放测试连接失败:%v", closeErr)`, + }, + keys: []string{ + "redis.backend.message.connect_success", + "redis.backend.error.test_connection_close_failed", + }, + }, "func (a *App) RedisSetString": { rawMessages: []string{`Message: "设置成功"`}, keys: []string{"redis.backend.message.set_success"}, @@ -129,6 +139,7 @@ func TestRedisBackendOperationMessageCatalogKeysExist(t *testing.T) { keys := []string{ "redis.backend.message.connect_success", + "redis.backend.error.test_connection_close_failed", "redis.backend.message.set_success", "redis.backend.message.select_db_success", "redis.backend.message.rename_success", diff --git a/internal/app/methods_redis_test.go b/internal/app/methods_redis_test.go index b9de677..ce77ed3 100644 --- a/internal/app/methods_redis_test.go +++ b/internal/app/methods_redis_test.go @@ -13,6 +13,7 @@ type capturingRedisClient struct { deletedHashKey string deletedHashFields []string closed int + closeErr error } func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error { @@ -22,7 +23,7 @@ func (c *capturingRedisClient) Connect(config connection.ConnectionConfig) error func (c *capturingRedisClient) Close() error { c.closed++ - return nil + return c.closeErr } func (c *capturingRedisClient) Ping() error { return nil } @@ -166,6 +167,49 @@ func TestRedisTestConnectionUsesIsolatedClientAndClosesIt(t *testing.T) { } } +func TestRedisTestConnectionReturnsLocalizedCloseFailure(t *testing.T) { + originalNewRedisClientFunc := newRedisClientFunc + originalResolveDialConfigWithProxyFunc := resolveDialConfigWithProxyFunc + proxySnapshot := currentGlobalProxyConfig() + defer func() { + newRedisClientFunc = originalNewRedisClientFunc + resolveDialConfigWithProxyFunc = originalResolveDialConfigWithProxyFunc + if _, err := setGlobalProxyConfig(proxySnapshot.Enabled, proxySnapshot.Proxy); err != nil { + t.Fatalf("restore global proxy failed: %v", err) + } + CloseAllRedisClients() + }() + CloseAllRedisClients() + if _, err := setGlobalProxyConfig(false, proxySnapshot.Proxy); err != nil { + t.Fatalf("disable global proxy failed: %v", err) + } + + client := &capturingRedisClient{closeErr: errors.New("close failed")} + newRedisClientFunc = func() redislib.RedisClient { + return client + } + resolveDialConfigWithProxyFunc = func(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { + return raw, nil + } + + app := NewApp() + result := app.RedisTestConnection(connection.ConnectionConfig{ + Type: "redis", + Host: "127.0.0.1", + Port: 6379, + }) + + if result.Success { + t.Fatalf("expected localized close failure, got success with %q", result.Message) + } + if want := app.appText("redis.backend.error.test_connection_close_failed", map[string]any{"detail": "close failed"}); result.Message != want { + t.Fatalf("expected localized close failure message %q, got %q", want, result.Message) + } + if client.closed != 1 { + t.Fatalf("expected isolated redis test client to be closed once, got %d", client.closed) + } +} + func TestRedisConnectResolvesSavedSecretsByConnectionID(t *testing.T) { testCases := []struct { name string diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 30de33a..348c3f9 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -1579,6 +1579,7 @@ "ai_service.backend.error.models_parse_failed": "Modellliste konnte nicht geparst werden: {{detail}}", "ai_service.backend.error.models_request_create_failed": "Modelllisten-Anfrage konnte nicht erstellt werden: {{detail}}", "ai_service.backend.error.models_request_failed": "Modellliste konnte nicht angefordert werden: {{detail}}", + "ai_service.backend.error.models_remote_unsupported": "Das Auflisten entfernter Modelle wird vom aktuellen Provider nicht unterstützt", "ai_service.backend.error.provider_auth_failed": "API Key ist ungültig oder die Anfrage wurde abgelehnt (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_server_error": "Upstream-Server hat einen internen Fehler zurückgegeben (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_status_failed": "Endpunkt hat einen unerwarteten Status zurückgegeben (HTTP {{status}}){{body}}", @@ -1596,6 +1597,7 @@ "ai_service.backend.error.session_corrupt": "Sitzungsdaten sind beschädigt", "ai_service.backend.error.session_delete_failed": "Sitzung konnte nicht gelöscht werden: {{detail}}", "ai_service.backend.error.session_missing": "Sitzung existiert nicht", + "ai_service.backend.error.session_provider_messages_serialize_failed": "Provider-Nachrichten der Sitzung konnten nicht serialisiert werden: {{detail}}", "ai_service.backend.error.session_serialize_failed": "Sitzungsdaten konnten nicht serialisiert werden: {{detail}}", "ai_service.backend.error.session_write_failed": "Sitzung konnte nicht gespeichert werden: {{detail}}", "ai_service.backend.error.sessions_dir_create_failed": "sessions-Verzeichnis konnte nicht erstellt werden: {{detail}}", @@ -4434,6 +4436,7 @@ "db.backend.error.schema_same_name": "Alter und neuer Schemaname müssen unterschiedlich sein", "db.backend.error.sqlite_file_path_required": "SQLite erfordert einen lokalen Datenbankdateipfad (z. B. /path/to/demo.sqlite)", "db.backend.error.sqlite_host_port_not_file_path": "SQLite erfordert einen lokalen Datenbankdateipfad; die aktuelle Eingabe sieht wie eine Hostadresse aus: {{dsn}}", + "db.backend.error.column_definitions_missing": "Es wurden keine Spaltendefinitionen zurückgegeben", "db.backend.error.table_columns_empty_for_ddl": "Die abgerufenen Spaltendefinitionen waren leer, daher konnte die CREATE TABLE-Anweisung nicht erzeugt werden", "db.backend.error.table_columns_missing_for_ddl": "Es konnten keine Spaltendefinitionen abgerufen werden, daher konnte die CREATE TABLE-Anweisung nicht erzeugt werden", "db.backend.error.table_drop_unsupported": "Die aktuelle Datenquelle ({{dbType}}) unterstützt das Löschen von Tabellen nicht", @@ -4908,6 +4911,7 @@ "file.backend.error.directory_name_no_separator": "Verzeichnisname darf keine Pfadtrennzeichen enthalten", "file.backend.error.directory_name_required": "Verzeichnisname darf nicht leer sein", "file.backend.error.directory_path_required": "Verzeichnispfad darf nicht leer sein", + "file.backend.error.export_driver_agent_streaming_required": "Für den Export ist das aktuelle {{driver}} driver-agent-Streaming-Protokoll erforderlich. Um bei großen Ergebnismengen einen Rückfall in den speicherintensiven Puffermodus zu vermeiden, installieren Sie es in der Treiberverwaltung neu und versuchen Sie es erneut: {{detail}}", "file.backend.error.export_unsupported_format": "Nicht unterstütztes Exportformat: {{format}}", "file.backend.error.file_path_empty": "Dateipfad ist leer", "file.backend.error.file_path_required": "Dateipfad darf nicht leer sein", @@ -7547,6 +7551,12 @@ "sql_analysis.slow_query.rail.aria_label": "Workbench fuer langsame SQL", "sql_analysis.backend.error.query_required": "Die SQL-Abfrage darf nicht leer sein", "sql_analysis.backend.error.select_only": "Die Diagnose unterstuetzt nur SELECT- / WITH-Abfragen. Fuer Schreiboperationen verwenden Sie bitte den EXPLAIN-PLAN-Modus (PR2-Unterstuetzung).", + "sql_analysis.backend.error.driver_explain_failed": "Driver-EXPLAIN-Ausführung fehlgeschlagen: {{detail}}", + "sql_analysis.backend.error.explain_execution_failed": "EXPLAIN-Ausführung fehlgeschlagen: {{detail}}", + "sql_analysis.backend.error.explain_result_missing": "Es wurde kein EXPLAIN-Ergebnissatz zurückgegeben", + "sql_analysis.backend.error.explain_result_empty": "Der EXPLAIN-Ergebnissatz war leer", + "sql_analysis.backend.error.explain_dialect_unsupported": "Der EXPLAIN-Dialekt für {{dbType}} wird nicht unterstützt", + "sql_analysis.backend.error.explain_query_not_implemented": "Die EXPLAIN-Abfrageerzeugung für {{dbType}} ist nicht implementiert", "sql_analysis.backend.error.unsupported_db_type": "Die aktuelle Datenquelle ({{dbType}}) unterstuetzt noch keine SQL-Diagnose. In Phase 1 werden MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase unterstuetzt.", "sql_analysis.backend.message.completed": "Diagnose abgeschlossen", "query_history.backend.error.connection_fingerprint_invalid": "Der Verbindungs-Fingerprint konnte nicht analysiert werden", @@ -7576,5 +7586,23 @@ "query_editor.message.connection_readonly_blocked": "Für diese Verbindung ist der Produktionsschutz aktiv; es sind nur Abfragen erlaubt.", "query_editor.results_panel.message.action.copy": "Kopieren", "query_editor.results_panel.message.copy_unsupported": "Die Zwischenablage ist in der aktuellen Umgebung nicht verfügbar", - "query_editor.results_panel.message.copy_failed": "Nachricht konnte nicht kopiert werden: {{detail}}" + "query_editor.results_panel.message.copy_failed": "Nachricht konnte nicht kopiert werden: {{detail}}", + "db.backend.error.test_connection_close_failed": "Verbindung erfolgreich, aber das Freigeben der Testverbindung ist fehlgeschlagen: {{detail}}", + "file.backend.dialog.select_batch_export_directory": "Stapel-Exportverzeichnis auswählen", + "redis.backend.error.test_connection_close_failed": "Verbindung erfolgreich, aber das Freigeben der Testverbindung ist fehlgeschlagen: {{detail}}", + "data_export.progress.rows_written": "{{current}} Zeilen geschrieben", + "data_export.progress.rows_written_with_total": "{{current}} / {{total}} Zeilen geschrieben", + "data_export.progress.stage.preparing_export": "Export wird vorbereitet", + "data_export.progress.stage.exporting_sql_file": "SQL-Datei wird exportiert", + "data_export.progress.stage.preparing_batch_tables_export": "Batch-Objektexport wird vorbereitet", + "data_export.progress.stage.preparing_batch_databases_export": "Batch-Datenbankexport wird vorbereitet", + "data_export.progress.stage.exporting_item_with_progress": "{{name}} wird exportiert ({{current}}/{{total}})", + "data_export.progress.stage.querying_data": "Daten werden abgefragt", + "data_export.progress.stage.writing_file": "Datei wird geschrieben", + "data_export.progress.stage.finalizing_file_write": "Dateischreiben wird abgeschlossen", + "data_export.progress.stage.finalizing_xlsx_package": "XLSX-Datei wird verpackt und komprimiert", + "data_export.progress.stage.finalizing_csv_write": "CSV-Schreiben wird abgeschlossen", + "data_export.progress.stage.export_failed": "Export fehlgeschlagen", + "sql_analysis.workbench.tab_title": "SQL-Analyse", + "sql_analysis.workbench.tab_title_with_database": "SQL-Analyse · {{database}}" } diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 0ee0cf4..4b454a1 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -1579,6 +1579,7 @@ "ai_service.backend.error.models_parse_failed": "Failed to parse model list: {{detail}}", "ai_service.backend.error.models_request_create_failed": "Failed to create model list request: {{detail}}", "ai_service.backend.error.models_request_failed": "Failed to request model list: {{detail}}", + "ai_service.backend.error.models_remote_unsupported": "Remote model listing is not supported for the current provider", "ai_service.backend.error.provider_auth_failed": "API key is invalid or the request was rejected (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_server_error": "Upstream server returned an internal error (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_status_failed": "Endpoint returned an unexpected status (HTTP {{status}}){{body}}", @@ -1596,6 +1597,7 @@ "ai_service.backend.error.session_corrupt": "Session data is corrupted", "ai_service.backend.error.session_delete_failed": "Failed to delete session: {{detail}}", "ai_service.backend.error.session_missing": "Session does not exist", + "ai_service.backend.error.session_provider_messages_serialize_failed": "Failed to serialize session provider messages: {{detail}}", "ai_service.backend.error.session_serialize_failed": "Failed to serialize session data: {{detail}}", "ai_service.backend.error.session_write_failed": "Failed to save session: {{detail}}", "ai_service.backend.error.sessions_dir_create_failed": "Failed to create sessions directory: {{detail}}", @@ -4434,6 +4436,7 @@ "db.backend.error.schema_same_name": "The old and new schema names must be different", "db.backend.error.sqlite_file_path_required": "SQLite requires a local database file path (for example /path/to/demo.sqlite)", "db.backend.error.sqlite_host_port_not_file_path": "SQLite requires a local database file path; the current input looks like a host address: {{dsn}}", + "db.backend.error.column_definitions_missing": "No column definitions were returned", "db.backend.error.table_columns_empty_for_ddl": "The retrieved column definitions were empty, so the CREATE TABLE statement could not be generated", "db.backend.error.table_columns_missing_for_ddl": "No column definitions were retrieved, so the CREATE TABLE statement could not be generated", "db.backend.error.table_drop_unsupported": "The current data source ({{dbType}}) does not support dropping tables", @@ -4908,6 +4911,7 @@ "file.backend.error.directory_name_no_separator": "Directory name cannot contain path separators", "file.backend.error.directory_name_required": "Directory name cannot be empty", "file.backend.error.directory_path_required": "Directory path cannot be empty", + "file.backend.error.export_driver_agent_streaming_required": "Export requires the latest {{driver}} driver-agent streaming protocol. To avoid falling back to high-memory buffered mode for large result sets, reinstall it from Driver Management and try again: {{detail}}", "file.backend.error.export_unsupported_format": "Unsupported export format: {{format}}", "file.backend.error.file_path_empty": "File path is empty", "file.backend.error.file_path_required": "File path cannot be empty", @@ -7547,6 +7551,12 @@ "sql_analysis.slow_query.rail.aria_label": "Slow SQL workbench", "sql_analysis.backend.error.query_required": "SQL statement cannot be empty", "sql_analysis.backend.error.select_only": "Diagnosis only supports SELECT / WITH queries. Use EXPLAIN PLAN mode for write operations (PR2 support).", + "sql_analysis.backend.error.driver_explain_failed": "Driver EXPLAIN execution failed: {{detail}}", + "sql_analysis.backend.error.explain_execution_failed": "EXPLAIN execution failed: {{detail}}", + "sql_analysis.backend.error.explain_result_missing": "No EXPLAIN result set was returned", + "sql_analysis.backend.error.explain_result_empty": "The EXPLAIN result set was empty", + "sql_analysis.backend.error.explain_dialect_unsupported": "The EXPLAIN dialect is not supported for {{dbType}}", + "sql_analysis.backend.error.explain_query_not_implemented": "EXPLAIN query generation is not implemented for {{dbType}}", "sql_analysis.backend.error.unsupported_db_type": "The current data source ({{dbType}}) does not support SQL diagnosis yet. Phase 1 supports MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase.", "sql_analysis.backend.message.completed": "Diagnosis completed", "query_history.backend.error.connection_fingerprint_invalid": "Unable to parse the connection fingerprint", @@ -7576,5 +7586,23 @@ "query_editor.message.connection_readonly_blocked": "This connection has production guard enabled and only allows query operations.", "query_editor.results_panel.message.action.copy": "Copy", "query_editor.results_panel.message.copy_unsupported": "Clipboard is not available in the current environment", - "query_editor.results_panel.message.copy_failed": "Failed to copy message: {{detail}}" + "query_editor.results_panel.message.copy_failed": "Failed to copy message: {{detail}}", + "db.backend.error.test_connection_close_failed": "Connection succeeded, but releasing the test connection failed: {{detail}}", + "file.backend.dialog.select_batch_export_directory": "Select batch export directory", + "redis.backend.error.test_connection_close_failed": "Connection succeeded, but releasing the test connection failed: {{detail}}", + "data_export.progress.rows_written": "Written {{current}} rows", + "data_export.progress.rows_written_with_total": "Written {{current}} / {{total}} rows", + "data_export.progress.stage.preparing_export": "Preparing export", + "data_export.progress.stage.exporting_sql_file": "Exporting SQL file", + "data_export.progress.stage.preparing_batch_tables_export": "Preparing batch object export", + "data_export.progress.stage.preparing_batch_databases_export": "Preparing batch database export", + "data_export.progress.stage.exporting_item_with_progress": "Exporting {{name}} ({{current}}/{{total}})", + "data_export.progress.stage.querying_data": "Querying data", + "data_export.progress.stage.writing_file": "Writing file", + "data_export.progress.stage.finalizing_file_write": "Finalizing file write", + "data_export.progress.stage.finalizing_xlsx_package": "Packaging and compressing XLSX file", + "data_export.progress.stage.finalizing_csv_write": "Finalizing CSV write", + "data_export.progress.stage.export_failed": "Export failed", + "sql_analysis.workbench.tab_title": "SQL analysis", + "sql_analysis.workbench.tab_title_with_database": "SQL analysis · {{database}}" } diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index 340a88c..fa0910f 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -1579,6 +1579,7 @@ "ai_service.backend.error.models_parse_failed": "モデル一覧の解析に失敗しました: {{detail}}", "ai_service.backend.error.models_request_create_failed": "モデル一覧リクエストの作成に失敗しました: {{detail}}", "ai_service.backend.error.models_request_failed": "モデル一覧のリクエストに失敗しました: {{detail}}", + "ai_service.backend.error.models_remote_unsupported": "現在の Provider はリモートモデル一覧をサポートしていません", "ai_service.backend.error.provider_auth_failed": "API Key が無効、またはリクエストが拒否されました (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_server_error": "上流サーバーが内部エラーを返しました (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_status_failed": "エンドポイントが予期しないステータスを返しました (HTTP {{status}}){{body}}", @@ -1596,6 +1597,7 @@ "ai_service.backend.error.session_corrupt": "セッションデータが破損しています", "ai_service.backend.error.session_delete_failed": "セッションの削除に失敗しました: {{detail}}", "ai_service.backend.error.session_missing": "セッションが存在しません", + "ai_service.backend.error.session_provider_messages_serialize_failed": "セッション Provider メッセージのシリアライズに失敗しました: {{detail}}", "ai_service.backend.error.session_serialize_failed": "セッションデータのシリアライズに失敗しました: {{detail}}", "ai_service.backend.error.session_write_failed": "セッションの保存に失敗しました: {{detail}}", "ai_service.backend.error.sessions_dir_create_failed": "sessions ディレクトリの作成に失敗しました: {{detail}}", @@ -4434,6 +4436,7 @@ "db.backend.error.schema_same_name": "変更前と変更後のスキーマ名は異なる必要があります", "db.backend.error.sqlite_file_path_required": "SQLite にはローカルデータベースファイルのパスが必要です(例: /path/to/demo.sqlite)", "db.backend.error.sqlite_host_port_not_file_path": "SQLite にはローカルデータベースファイルのパスが必要です。現在の入力はホストアドレスのようです: {{dsn}}", + "db.backend.error.column_definitions_missing": "カラム定義が返されませんでした", "db.backend.error.table_columns_empty_for_ddl": "列定義が空のため、CREATE TABLE 文を生成できません", "db.backend.error.table_columns_missing_for_ddl": "列定義を取得できないため、CREATE TABLE 文を生成できません", "db.backend.error.table_drop_unsupported": "現在のデータソース({{dbType}})はテーブルの削除をサポートしていません", @@ -4908,6 +4911,7 @@ "file.backend.error.directory_name_no_separator": "ディレクトリ名にパス区切り文字は使用できません", "file.backend.error.directory_name_required": "ディレクトリ名は空にできません", "file.backend.error.directory_path_required": "ディレクトリパスは空にできません", + "file.backend.error.export_driver_agent_streaming_required": "現在のエクスポートには最新の {{driver}} driver-agent ストリーミングプロトコルが必要です。大きな結果セットで高メモリのバッファモードへフォールバックしないよう、Driver Management で再インストールしてから再試行してください: {{detail}}", "file.backend.error.export_unsupported_format": "サポートされていないエクスポート形式です: {{format}}", "file.backend.error.file_path_empty": "ファイルパスが空です", "file.backend.error.file_path_required": "ファイルパスは空にできません", @@ -7547,6 +7551,12 @@ "sql_analysis.slow_query.rail.aria_label": "遅い SQL ワークベンチ", "sql_analysis.backend.error.query_required": "クエリを空にすることはできません", "sql_analysis.backend.error.select_only": "診断は SELECT / WITH クエリのみサポートします。更新系は EXPLAIN PLAN モードを使用してください(PR2 対応)。", + "sql_analysis.backend.error.driver_explain_failed": "ドライバーの EXPLAIN 実行に失敗しました: {{detail}}", + "sql_analysis.backend.error.explain_execution_failed": "EXPLAIN の実行に失敗しました: {{detail}}", + "sql_analysis.backend.error.explain_result_missing": "EXPLAIN の結果セットが返されませんでした", + "sql_analysis.backend.error.explain_result_empty": "EXPLAIN の結果セットは空でした", + "sql_analysis.backend.error.explain_dialect_unsupported": "このデータソース({{dbType}})の EXPLAIN 方言はサポートされていません", + "sql_analysis.backend.error.explain_query_not_implemented": "このデータソース({{dbType}})向けの EXPLAIN クエリ生成は未実装です", "sql_analysis.backend.error.unsupported_db_type": "現在のデータソース({{dbType}})はまだ SQL 診断をサポートしていません。第1期では MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase をサポートします。", "sql_analysis.backend.message.completed": "診断が完了しました", "query_history.backend.error.connection_fingerprint_invalid": "接続フィンガープリントを解析できません", @@ -7576,5 +7586,23 @@ "query_editor.message.connection_readonly_blocked": "この接続では本番保護が有効なため、問い合わせ操作のみ実行できます。", "query_editor.results_panel.message.action.copy": "コピー", "query_editor.results_panel.message.copy_unsupported": "現在の環境ではクリップボードへコピーできません", - "query_editor.results_panel.message.copy_failed": "メッセージのコピーに失敗しました: {{detail}}" + "query_editor.results_panel.message.copy_failed": "メッセージのコピーに失敗しました: {{detail}}", + "db.backend.error.test_connection_close_failed": "接続には成功しましたが、テスト接続の解放に失敗しました:{{detail}}", + "file.backend.dialog.select_batch_export_directory": "一括エクスポート先ディレクトリを選択", + "redis.backend.error.test_connection_close_failed": "接続には成功しましたが、テスト接続の解放に失敗しました:{{detail}}", + "data_export.progress.rows_written": "{{current}} 行を書き込みました", + "data_export.progress.rows_written_with_total": "{{current}} / {{total}} 行を書き込みました", + "data_export.progress.stage.preparing_export": "エクスポートを準備しています", + "data_export.progress.stage.exporting_sql_file": "SQL ファイルをエクスポートしています", + "data_export.progress.stage.preparing_batch_tables_export": "一括オブジェクト エクスポートを準備しています", + "data_export.progress.stage.preparing_batch_databases_export": "一括データベース エクスポートを準備しています", + "data_export.progress.stage.exporting_item_with_progress": "{{name}} をエクスポートしています ({{current}}/{{total}})", + "data_export.progress.stage.querying_data": "データを問い合わせています", + "data_export.progress.stage.writing_file": "ファイルに書き込んでいます", + "data_export.progress.stage.finalizing_file_write": "ファイル書き込みを完了しています", + "data_export.progress.stage.finalizing_xlsx_package": "XLSX ファイルをパッケージ化して圧縮しています", + "data_export.progress.stage.finalizing_csv_write": "CSV 書き込みを完了しています", + "data_export.progress.stage.export_failed": "エクスポートに失敗しました", + "sql_analysis.workbench.tab_title": "SQL 分析", + "sql_analysis.workbench.tab_title_with_database": "SQL 分析 · {{database}}" } diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index 5a0ebb4..03a355b 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -1579,6 +1579,7 @@ "ai_service.backend.error.models_parse_failed": "Не удалось разобрать список моделей: {{detail}}", "ai_service.backend.error.models_request_create_failed": "Не удалось создать запрос списка моделей: {{detail}}", "ai_service.backend.error.models_request_failed": "Не удалось запросить список моделей: {{detail}}", + "ai_service.backend.error.models_remote_unsupported": "Текущий провайдер не поддерживает удаленный список моделей", "ai_service.backend.error.provider_auth_failed": "API Key недействителен или запрос был отклонен (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_server_error": "Вышестоящий сервер вернул внутреннюю ошибку (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_status_failed": "Эндпоинт вернул неожиданный статус (HTTP {{status}}){{body}}", @@ -1596,6 +1597,7 @@ "ai_service.backend.error.session_corrupt": "Данные сеанса повреждены", "ai_service.backend.error.session_delete_failed": "Не удалось удалить сеанс: {{detail}}", "ai_service.backend.error.session_missing": "Сеанс не существует", + "ai_service.backend.error.session_provider_messages_serialize_failed": "Не удалось сериализовать сообщения провайдера сеанса: {{detail}}", "ai_service.backend.error.session_serialize_failed": "Не удалось сериализовать данные сеанса: {{detail}}", "ai_service.backend.error.session_write_failed": "Не удалось сохранить сеанс: {{detail}}", "ai_service.backend.error.sessions_dir_create_failed": "Не удалось создать каталог sessions: {{detail}}", @@ -4434,6 +4436,7 @@ "db.backend.error.schema_same_name": "Старое и новое имена схемы должны отличаться", "db.backend.error.sqlite_file_path_required": "SQLite требуется путь к локальному файлу базы данных (например, /path/to/demo.sqlite)", "db.backend.error.sqlite_host_port_not_file_path": "SQLite требуется путь к локальному файлу базы данных; текущий ввод похож на адрес хоста: {{dsn}}", + "db.backend.error.column_definitions_missing": "Определения столбцов не были получены", "db.backend.error.table_columns_empty_for_ddl": "Полученные определения столбцов оказались пустыми, поэтому не удалось сформировать инструкцию CREATE TABLE", "db.backend.error.table_columns_missing_for_ddl": "Не удалось получить определения столбцов, поэтому не удалось сформировать инструкцию CREATE TABLE", "db.backend.error.table_drop_unsupported": "Текущий источник данных ({{dbType}}) не поддерживает удаление таблиц", @@ -4908,6 +4911,7 @@ "file.backend.error.directory_name_no_separator": "Имя каталога не может содержать разделители пути", "file.backend.error.directory_name_required": "Имя каталога не может быть пустым", "file.backend.error.directory_path_required": "Путь к каталогу не может быть пустым", + "file.backend.error.export_driver_agent_streaming_required": "Для экспорта требуется актуальный потоковый протокол driver-agent для {{driver}}. Чтобы избежать отката к буферизованному режиму с высоким потреблением памяти при больших результатах, переустановите его в управлении драйверами и повторите попытку: {{detail}}", "file.backend.error.export_unsupported_format": "Неподдерживаемый формат экспорта: {{format}}", "file.backend.error.file_path_empty": "Путь к файлу пуст", "file.backend.error.file_path_required": "Путь к файлу не может быть пустым", @@ -7547,6 +7551,12 @@ "sql_analysis.slow_query.rail.aria_label": "Рабочая область медленных SQL", "sql_analysis.backend.error.query_required": "SQL-запрос не может быть пустым", "sql_analysis.backend.error.select_only": "Диагностика поддерживает только запросы SELECT / WITH. Для операций записи используйте режим EXPLAIN PLAN (поддержка PR2).", + "sql_analysis.backend.error.driver_explain_failed": "Ошибка выполнения EXPLAIN драйвером: {{detail}}", + "sql_analysis.backend.error.explain_execution_failed": "Ошибка выполнения EXPLAIN: {{detail}}", + "sql_analysis.backend.error.explain_result_missing": "Набор результатов EXPLAIN не был возвращён", + "sql_analysis.backend.error.explain_result_empty": "Набор результатов EXPLAIN пуст", + "sql_analysis.backend.error.explain_dialect_unsupported": "Диалект EXPLAIN для {{dbType}} не поддерживается", + "sql_analysis.backend.error.explain_query_not_implemented": "Генерация EXPLAIN-запроса для {{dbType}} не реализована", "sql_analysis.backend.error.unsupported_db_type": "Текущий источник данных ({{dbType}}) пока не поддерживает диагностику SQL. На первом этапе поддерживаются MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase.", "sql_analysis.backend.message.completed": "Диагностика завершена", "query_history.backend.error.connection_fingerprint_invalid": "Не удалось разобрать отпечаток подключения", @@ -7576,5 +7586,23 @@ "query_editor.message.connection_readonly_blocked": "Для этого подключения включена защита production, разрешены только операции запроса.", "query_editor.results_panel.message.action.copy": "Копировать", "query_editor.results_panel.message.copy_unsupported": "Буфер обмена недоступен в текущей среде", - "query_editor.results_panel.message.copy_failed": "Не удалось скопировать сообщение: {{detail}}" + "query_editor.results_panel.message.copy_failed": "Не удалось скопировать сообщение: {{detail}}", + "db.backend.error.test_connection_close_failed": "Подключение выполнено, но не удалось закрыть тестовое подключение: {{detail}}", + "file.backend.dialog.select_batch_export_directory": "Выберите каталог для пакетного экспорта", + "redis.backend.error.test_connection_close_failed": "Подключение выполнено, но не удалось закрыть тестовое подключение: {{detail}}", + "data_export.progress.rows_written": "Записано {{current}} строк", + "data_export.progress.rows_written_with_total": "Записано {{current}} / {{total}} строк", + "data_export.progress.stage.preparing_export": "Подготовка экспорта", + "data_export.progress.stage.exporting_sql_file": "Экспорт SQL-файла", + "data_export.progress.stage.preparing_batch_tables_export": "Подготовка пакетного экспорта объектов", + "data_export.progress.stage.preparing_batch_databases_export": "Подготовка пакетного экспорта баз данных", + "data_export.progress.stage.exporting_item_with_progress": "Экспорт {{name}} ({{current}}/{{total}})", + "data_export.progress.stage.querying_data": "Запрос данных", + "data_export.progress.stage.writing_file": "Запись файла", + "data_export.progress.stage.finalizing_file_write": "Завершение записи файла", + "data_export.progress.stage.finalizing_xlsx_package": "Упаковка и сжатие XLSX-файла", + "data_export.progress.stage.finalizing_csv_write": "Завершение записи CSV", + "data_export.progress.stage.export_failed": "Ошибка экспорта", + "sql_analysis.workbench.tab_title": "SQL-анализ", + "sql_analysis.workbench.tab_title_with_database": "SQL-анализ · {{database}}" } diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 97421b8..1227709 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -1579,6 +1579,7 @@ "ai_service.backend.error.models_parse_failed": "解析模型列表失败: {{detail}}", "ai_service.backend.error.models_request_create_failed": "创建模型列表请求失败: {{detail}}", "ai_service.backend.error.models_request_failed": "请求模型列表失败: {{detail}}", + "ai_service.backend.error.models_remote_unsupported": "当前供应商不支持远端模型列表", "ai_service.backend.error.provider_auth_failed": "API Key 无效或请求被拒绝 (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_server_error": "上游服务器返回内部错误 (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_status_failed": "接口返回异常状态 (HTTP {{status}}){{body}}", @@ -1596,6 +1597,7 @@ "ai_service.backend.error.session_corrupt": "会话数据损坏", "ai_service.backend.error.session_delete_failed": "删除会话失败: {{detail}}", "ai_service.backend.error.session_missing": "会话不存在", + "ai_service.backend.error.session_provider_messages_serialize_failed": "序列化会话 Provider 消息失败: {{detail}}", "ai_service.backend.error.session_serialize_failed": "序列化会话数据失败: {{detail}}", "ai_service.backend.error.session_write_failed": "保存会话失败: {{detail}}", "ai_service.backend.error.sessions_dir_create_failed": "创建 sessions 目录失败: {{detail}}", @@ -4434,6 +4436,7 @@ "db.backend.error.schema_same_name": "新旧模式名称不能相同", "db.backend.error.sqlite_file_path_required": "SQLite 需要本地数据库文件路径(例如 /path/to/demo.sqlite)", "db.backend.error.sqlite_host_port_not_file_path": "SQLite 需要本地数据库文件路径,当前输入看起来是主机地址:{{dsn}}", + "db.backend.error.column_definitions_missing": "未获取到字段定义", "db.backend.error.table_columns_empty_for_ddl": "字段定义为空,无法生成建表语句", "db.backend.error.table_columns_missing_for_ddl": "未获取到字段定义,无法生成建表语句", "db.backend.error.table_drop_unsupported": "当前数据源({{dbType}})暂不支持删除表", @@ -4908,6 +4911,7 @@ "file.backend.error.directory_name_no_separator": "目录名不能包含路径分隔符", "file.backend.error.directory_name_required": "目录名不能为空", "file.backend.error.directory_path_required": "目录路径不能为空", + "file.backend.error.export_driver_agent_streaming_required": "当前导出依赖最新的 {{driver}} driver-agent 流式协议;为避免大结果集回退到高内存缓冲模式,请在驱动管理中重装后重试:{{detail}}", "file.backend.error.export_unsupported_format": "不支持的导出格式: {{format}}", "file.backend.error.file_path_empty": "文件路径为空", "file.backend.error.file_path_required": "文件路径不能为空", @@ -7547,6 +7551,12 @@ "sql_analysis.slow_query.rail.aria_label": "慢 SQL 工作台", "sql_analysis.backend.error.query_required": "查询语句不能为空", "sql_analysis.backend.error.select_only": "诊断仅支持 SELECT / WITH 查询;写操作请使用 EXPLAIN PLAN 模式(PR2 支持)", + "sql_analysis.backend.error.driver_explain_failed": "驱动 EXPLAIN 执行失败:{{detail}}", + "sql_analysis.backend.error.explain_execution_failed": "执行 EXPLAIN 失败:{{detail}}", + "sql_analysis.backend.error.explain_result_missing": "未返回 EXPLAIN 结果集", + "sql_analysis.backend.error.explain_result_empty": "EXPLAIN 结果集为空", + "sql_analysis.backend.error.explain_dialect_unsupported": "当前数据源({{dbType}})的 EXPLAIN 方言暂不支持", + "sql_analysis.backend.error.explain_query_not_implemented": "当前数据源({{dbType}})暂未实现 EXPLAIN 语句生成", "sql_analysis.backend.error.unsupported_db_type": "当前数据源({{dbType}})暂不支持 SQL 诊断;一期支持 MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase", "sql_analysis.backend.message.completed": "诊断完成", "query_history.backend.error.connection_fingerprint_invalid": "无法解析连接指纹", @@ -7576,5 +7586,23 @@ "query_editor.message.connection_readonly_blocked": "当前连接已启用生产保护,仅允许执行查询操作。", "query_editor.results_panel.message.action.copy": "复制", "query_editor.results_panel.message.copy_unsupported": "当前环境不支持复制到剪贴板", - "query_editor.results_panel.message.copy_failed": "复制消息失败:{{detail}}" + "query_editor.results_panel.message.copy_failed": "复制消息失败:{{detail}}", + "db.backend.error.test_connection_close_failed": "连接成功,但释放测试连接失败:{{detail}}", + "file.backend.dialog.select_batch_export_directory": "选择批量导出目录", + "redis.backend.error.test_connection_close_failed": "连接成功,但释放测试连接失败:{{detail}}", + "data_export.progress.rows_written": "已写入 {{current}} 行", + "data_export.progress.rows_written_with_total": "已写入 {{current}} / {{total}} 行", + "data_export.progress.stage.preparing_export": "正在准备导出", + "data_export.progress.stage.exporting_sql_file": "正在导出 SQL 文件", + "data_export.progress.stage.preparing_batch_tables_export": "正在准备批量对象导出", + "data_export.progress.stage.preparing_batch_databases_export": "正在准备批量库导出", + "data_export.progress.stage.exporting_item_with_progress": "正在导出 {{name}} ({{current}}/{{total}})", + "data_export.progress.stage.querying_data": "正在查询数据", + "data_export.progress.stage.writing_file": "正在写入文件", + "data_export.progress.stage.finalizing_file_write": "正在完成文件写入", + "data_export.progress.stage.finalizing_xlsx_package": "正在封装并压缩 XLSX 文件", + "data_export.progress.stage.finalizing_csv_write": "正在完成 CSV 写入", + "data_export.progress.stage.export_failed": "导出失败", + "sql_analysis.workbench.tab_title": "SQL 分析", + "sql_analysis.workbench.tab_title_with_database": "SQL 分析 · {{database}}" } diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index bf5b1ee..03b765e 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -1579,6 +1579,7 @@ "ai_service.backend.error.models_parse_failed": "解析模型列表失敗: {{detail}}", "ai_service.backend.error.models_request_create_failed": "建立模型列表請求失敗: {{detail}}", "ai_service.backend.error.models_request_failed": "請求模型列表失敗: {{detail}}", + "ai_service.backend.error.models_remote_unsupported": "當前供應商不支援遠端模型列表", "ai_service.backend.error.provider_auth_failed": "API Key 無效或請求被拒絕 (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_server_error": "上游伺服器返回內部錯誤 (HTTP {{status}}){{body}}", "ai_service.backend.error.provider_http_status_failed": "端點返回異常狀態 (HTTP {{status}}){{body}}", @@ -1596,6 +1597,7 @@ "ai_service.backend.error.session_corrupt": "會話資料損壞", "ai_service.backend.error.session_delete_failed": "刪除會話失敗: {{detail}}", "ai_service.backend.error.session_missing": "會話不存在", + "ai_service.backend.error.session_provider_messages_serialize_failed": "序列化會話 Provider 訊息失敗: {{detail}}", "ai_service.backend.error.session_serialize_failed": "序列化會話資料失敗: {{detail}}", "ai_service.backend.error.session_write_failed": "儲存會話失敗: {{detail}}", "ai_service.backend.error.sessions_dir_create_failed": "建立 sessions 目錄失敗: {{detail}}", @@ -4434,6 +4436,7 @@ "db.backend.error.schema_same_name": "新舊模式名稱不能相同", "db.backend.error.sqlite_file_path_required": "SQLite 需要本機資料庫檔案路徑(例如 /path/to/demo.sqlite)", "db.backend.error.sqlite_host_port_not_file_path": "SQLite 需要本機資料庫檔案路徑,目前輸入看起來像主機位址:{{dsn}}", + "db.backend.error.column_definitions_missing": "未取得欄位定義", "db.backend.error.table_columns_empty_for_ddl": "欄位定義為空,無法產生建表語句", "db.backend.error.table_columns_missing_for_ddl": "未取得欄位定義,無法產生建表語句", "db.backend.error.table_drop_unsupported": "目前資料來源({{dbType}})暫不支援刪除資料表", @@ -4908,6 +4911,7 @@ "file.backend.error.directory_name_no_separator": "目錄名稱不能包含路徑分隔符", "file.backend.error.directory_name_required": "目錄名稱不能為空", "file.backend.error.directory_path_required": "目錄路徑不能為空", + "file.backend.error.export_driver_agent_streaming_required": "當前匯出依賴最新的 {{driver}} driver-agent 串流協議;為避免大結果集回退到高記憶體緩衝模式,請在驅動管理中重新安裝後再試:{{detail}}", "file.backend.error.export_unsupported_format": "不支援的匯出格式: {{format}}", "file.backend.error.file_path_empty": "檔案路徑為空", "file.backend.error.file_path_required": "檔案路徑不能為空", @@ -7547,6 +7551,12 @@ "sql_analysis.slow_query.rail.aria_label": "慢 SQL 工作台", "sql_analysis.backend.error.query_required": "查詢語句不能為空", "sql_analysis.backend.error.select_only": "診斷僅支援 SELECT / WITH 查詢;寫入操作請改用 EXPLAIN PLAN 模式(PR2 支援)", + "sql_analysis.backend.error.driver_explain_failed": "驅動 EXPLAIN 執行失敗:{{detail}}", + "sql_analysis.backend.error.explain_execution_failed": "執行 EXPLAIN 失敗:{{detail}}", + "sql_analysis.backend.error.explain_result_missing": "未返回 EXPLAIN 結果集", + "sql_analysis.backend.error.explain_result_empty": "EXPLAIN 結果集為空", + "sql_analysis.backend.error.explain_dialect_unsupported": "目前資料來源({{dbType}})的 EXPLAIN 方言暫不支援", + "sql_analysis.backend.error.explain_query_not_implemented": "目前資料來源({{dbType}})尚未實作 EXPLAIN 語句產生", "sql_analysis.backend.error.unsupported_db_type": "目前資料來源({{dbType}})暫不支援 SQL 診斷;一期支援 MySQL/PostgreSQL/SQLite/ClickHouse/Oracle/SQLServer/OceanBase", "sql_analysis.backend.message.completed": "診斷完成", "query_history.backend.error.connection_fingerprint_invalid": "無法解析連線指紋", @@ -7576,5 +7586,23 @@ "query_editor.message.connection_readonly_blocked": "目前連線已啟用正式保護,僅允許執行查詢操作。", "query_editor.results_panel.message.action.copy": "複製", "query_editor.results_panel.message.copy_unsupported": "目前環境不支援複製到剪貼簿", - "query_editor.results_panel.message.copy_failed": "複製訊息失敗:{{detail}}" + "query_editor.results_panel.message.copy_failed": "複製訊息失敗:{{detail}}", + "db.backend.error.test_connection_close_failed": "連線成功,但釋放測試連線失敗:{{detail}}", + "file.backend.dialog.select_batch_export_directory": "選擇批量匯出目錄", + "redis.backend.error.test_connection_close_failed": "連線成功,但釋放測試連線失敗:{{detail}}", + "data_export.progress.rows_written": "已寫入 {{current}} 行", + "data_export.progress.rows_written_with_total": "已寫入 {{current}} / {{total}} 行", + "data_export.progress.stage.preparing_export": "正在準備匯出", + "data_export.progress.stage.exporting_sql_file": "正在匯出 SQL 檔案", + "data_export.progress.stage.preparing_batch_tables_export": "正在準備批量物件匯出", + "data_export.progress.stage.preparing_batch_databases_export": "正在準備批量資料庫匯出", + "data_export.progress.stage.exporting_item_with_progress": "正在匯出 {{name}} ({{current}}/{{total}})", + "data_export.progress.stage.querying_data": "正在查詢資料", + "data_export.progress.stage.writing_file": "正在寫入檔案", + "data_export.progress.stage.finalizing_file_write": "正在完成檔案寫入", + "data_export.progress.stage.finalizing_xlsx_package": "正在封裝並壓縮 XLSX 檔案", + "data_export.progress.stage.finalizing_csv_write": "正在完成 CSV 寫入", + "data_export.progress.stage.export_failed": "匯出失敗", + "sql_analysis.workbench.tab_title": "SQL 分析", + "sql_analysis.workbench.tab_title_with_database": "SQL 分析 · {{database}}" }