From 255cc14bf64150286b9e0f1503c7b2895dbe7c14 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:51:32 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(config-secret-storage):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=86=E6=96=87=E7=BC=96=E8=BE=91=E4=B8=8E?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=AE=8B=E7=95=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复自定义连接编辑时已保存 DSN 无法留空沿用的问题 - 重置 AI 供应商编辑态与清空密钥开关,避免关闭后状态残留 - 对齐浏览器 mock 复制连接的 config.id 语义并补充回归测试 --- frontend/src/components/AISettingsModal.tsx | 75 ++++++++++----- frontend/src/components/ConnectionModal.tsx | 20 +++- frontend/src/main.tsx | 44 ++------- .../src/utils/aiProviderEditorState.test.ts | 49 ++++++++++ frontend/src/utils/aiProviderEditorState.ts | 92 +++++++++++++++++++ .../src/utils/browserMockConnections.test.ts | 26 ++++++ frontend/src/utils/browserMockConnections.ts | 47 ++++++++++ .../src/utils/customConnectionDsn.test.ts | 37 ++++++++ frontend/src/utils/customConnectionDsn.ts | 27 ++++++ 9 files changed, 355 insertions(+), 62 deletions(-) create mode 100644 frontend/src/utils/aiProviderEditorState.test.ts create mode 100644 frontend/src/utils/aiProviderEditorState.ts create mode 100644 frontend/src/utils/browserMockConnections.test.ts create mode 100644 frontend/src/utils/browserMockConnections.ts create mode 100644 frontend/src/utils/customConnectionDsn.test.ts create mode 100644 frontend/src/utils/customConnectionDsn.ts diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 7d586a4..ecd4b62 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd'; import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons'; import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types'; @@ -19,6 +19,7 @@ import { PROVIDER_PRESET_CARD_TITLE_STYLE, } from '../utils/aiSettingsPresetLayout'; import { resolveProviderSecretDraft } from '../utils/providerSecretDraft'; +import { buildAddProviderEditorSession, buildClosedProviderEditorSession, buildEditProviderEditorSession, type ProviderEditorSession } from '../utils/aiProviderEditorState'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; @@ -134,18 +135,41 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo useEffect(() => { if (open) void loadConfig(); }, [open, loadConfig]); + const applyProviderEditorSession = useCallback((session: ProviderEditorSession) => { + setEditingProvider(session.editingProvider as AIProviderConfig | null); + setIsEditing(session.isEditing); + setTestStatus(session.testStatus); + setClearProviderSecret(session.clearProviderSecret); + form.resetFields(); + if (session.formValues) { + form.setFieldsValue(session.formValues); + } + }, [form]); + + const resetProviderEditorSession = useCallback(() => { + applyProviderEditorSession(buildClosedProviderEditorSession()); + }, [applyProviderEditorSession]); + + const handleModalClose = useCallback(() => { + resetProviderEditorSession(); + onClose(); + }, [onClose, resetProviderEditorSession]); + + useEffect(() => { + if (!open) { + resetProviderEditorSession(); + } + }, [open, resetProviderEditorSession]); const handleAddProvider = () => { const preset = findPreset('openai'); - const newProvider: AIProviderConfig = { - id: '', type: preset.backendType, name: '', apiKey: '', - baseUrl: preset.defaultBaseUrl, model: preset.defaultModel, - models: [], maxTokens: 4096, temperature: 0.7, - }; - setEditingProvider({ ...newProvider, presetKey: 'openai' } as any); - setIsEditing(true); - setTestStatus('idle'); - form.resetFields(); - form.setFieldsValue({ ...newProvider, presetKey: 'openai', apiFormat: 'openai' }); + applyProviderEditorSession(buildAddProviderEditorSession({ + presetKey: 'openai', + presetBackendType: preset.backendType, + presetBaseUrl: preset.defaultBaseUrl, + presetModel: preset.defaultModel, + presetModels: preset.models, + apiFormat: 'openai', + })); }; const handleEditProvider = (p: AIProviderConfig) => { @@ -156,17 +180,16 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo presetFixedApiFormat: matchedPreset.fixedApiFormat, valuesApiFormat: p.apiFormat, }); - setEditingProvider(p); - setIsEditing(true); - setTestStatus('idle'); - form.resetFields(); - form.setFieldsValue({ - ...p, - type: resolvedTransport.type, - models: p.models || [], - presetKey: matchedPreset.key, - apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai', - }); + applyProviderEditorSession(buildEditProviderEditorSession({ + provider: { ...p, presetKey: matchedPreset.key } as any, + formValues: { + ...p, + type: resolvedTransport.type, + models: p.models || [], + presetKey: matchedPreset.key, + apiFormat: resolvedTransport.apiFormat || p.apiFormat || 'openai', + }, + })); }; const handleDeleteProvider = async (id: string) => { @@ -239,7 +262,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }; // 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常 await Service?.AISaveProvider?.(payload); - void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); setClearProviderSecret(false); void loadConfig(); + void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig(); window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); } catch (e: any) { if (e?.errorFields) { /* antd form validation error, ignore */ } @@ -420,7 +443,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
{/* 顶部返回 */}
- {editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'} @@ -732,7 +755,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
} open={open} - onCancel={onClose} + onCancel={handleModalClose} footer={null} width={820} styles={{ @@ -802,3 +825,5 @@ export default AISettingsModal; + + diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index b0f0763..0f0dec8 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd'; import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons'; import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons'; @@ -6,6 +6,7 @@ import { useStore } from '../store'; import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft'; +import { getCustomConnectionDsnValidationMessage } from '../utils/customConnectionDsn'; import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App'; import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types'; @@ -819,6 +820,19 @@ const ConnectionModal: React.FC<{ } }); + const createCustomDsnRule = () => ({ + validator(_: unknown, value: unknown) { + const validationMessage = getCustomConnectionDsnValidationMessage({ + dsnInput: value, + hasStoredSecret: initialValues?.hasOpaqueDSN, + clearStoredSecret: clearSecrets.opaqueDSN, + }); + return validationMessage + ? Promise.reject(new Error(validationMessage)) + : Promise.resolve(); + } + }); + const getUriPlaceholder = () => { if (dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') { const defaultPort = getDefaultPortByType(dbType); @@ -2100,7 +2114,7 @@ const ConnectionModal: React.FC<{ - + {renderStoredSecretControls({ @@ -3108,3 +3122,5 @@ export default ConnectionModal; + + diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index becce4a..f1c7eab 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from 'react' import ReactDOM from 'react-dom/client' import App from './App' // import './index.css' // Optional global styles @@ -15,35 +15,9 @@ dayjs.locale('zh-cn') import 'monaco-editor/esm/nls.messages.zh-cn' import { loader } from '@monaco-editor/react' import * as monaco from 'monaco-editor' +import { cloneBrowserMockValue, duplicateBrowserMockConnection, resolveBrowserMockSecretFlag } from './utils/browserMockConnections' loader.config({ monaco }) -const cloneBrowserMockValue = (value: any) => { - try { - return JSON.parse(JSON.stringify(value)); - } catch { - return value; - } -}; - -const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => { - if (String(nextValue ?? '') !== '') return true; - if (clearFlag) return false; - return !!existingFlag; -}; - -const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => { - const baseName = String(rawName || '').trim() || '连接'; - const suffix = ' - 副本'; - const usedNames = new Set(items.map((item) => String(item?.name || '').trim())); - let candidate = `${baseName}${suffix}`; - let counter = 2; - while (usedNames.has(candidate)) { - candidate = `${baseName}${suffix} ${counter}`; - counter += 1; - } - return candidate; -}; - if (typeof window !== 'undefined' && !(window as any).go) { const mockConnections: any[] = []; let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false }; @@ -124,13 +98,10 @@ if (typeof window !== 'undefined' && !(window as any).go) { DuplicateConnection: async (id: string) => { const existing = mockConnections.find((item) => item.id === id); if (!existing) return null; - const duplicated = cloneBrowserMockValue({ - ...existing, - id: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - name: buildBrowserMockDuplicateName(existing.name, mockConnections), - config: cloneBrowserMockValue(existing.config), - includeDatabases: Array.isArray(existing.includeDatabases) ? [...existing.includeDatabases] : undefined, - includeRedisDatabases: Array.isArray(existing.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined, + const duplicated = duplicateBrowserMockConnection({ + existing, + items: mockConnections, + nextId: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, }); mockConnections.push(duplicated); return cloneBrowserMockValue(duplicated); @@ -174,3 +145,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render( , ) + + + diff --git a/frontend/src/utils/aiProviderEditorState.test.ts b/frontend/src/utils/aiProviderEditorState.test.ts new file mode 100644 index 0000000..f869d4b --- /dev/null +++ b/frontend/src/utils/aiProviderEditorState.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildAddProviderEditorSession, + buildClosedProviderEditorSession, + buildEditProviderEditorSession, +} from './aiProviderEditorState'; + +describe('aiProviderEditorState', () => { + it('resets clearProviderSecret when starting add flow', () => { + const session = buildAddProviderEditorSession({ + previousClearProviderSecret: true, + presetBackendType: 'openai', + presetBaseUrl: 'https://api.openai.com/v1', + presetModel: 'gpt-4.1', + }); + + expect(session.clearProviderSecret).toBe(false); + expect(session.isEditing).toBe(true); + expect(session.testStatus).toBe('idle'); + }); + + it('resets clearProviderSecret when starting edit flow', () => { + const session = buildEditProviderEditorSession({ + previousClearProviderSecret: true, + provider: { + id: 'provider-1', + type: 'openai', + name: 'OpenAI', + apiKey: '', + hasSecret: true, + }, + }); + + expect(session.clearProviderSecret).toBe(false); + expect(session.isEditing).toBe(true); + expect(session.editingProvider?.id).toBe('provider-1'); + }); + + it('resets clearProviderSecret when the modal closes', () => { + const session = buildClosedProviderEditorSession({ + previousClearProviderSecret: true, + }); + + expect(session.clearProviderSecret).toBe(false); + expect(session.isEditing).toBe(false); + expect(session.editingProvider).toBeNull(); + }); +}); diff --git a/frontend/src/utils/aiProviderEditorState.ts b/frontend/src/utils/aiProviderEditorState.ts new file mode 100644 index 0000000..6ce5e0f --- /dev/null +++ b/frontend/src/utils/aiProviderEditorState.ts @@ -0,0 +1,92 @@ +import type { AIProviderConfig, AIProviderType } from '../types'; + +type ProviderEditorStatus = 'idle' | 'success' | 'error'; + +type ProviderEditorConfig = Partial & Pick & { presetKey?: string }; + +export interface ProviderEditorSession { + editingProvider: ProviderEditorConfig | null; + formValues: Record | null; + isEditing: boolean; + clearProviderSecret: boolean; + testStatus: ProviderEditorStatus; +} + +interface BuildAddProviderEditorSessionInput { + previousClearProviderSecret?: boolean; + presetKey?: string; + presetBackendType: AIProviderType; + presetBaseUrl: string; + presetModel: string; + presetModels?: string[]; + apiFormat?: string; +} + +interface BuildEditProviderEditorSessionInput { + previousClearProviderSecret?: boolean; + provider: ProviderEditorConfig; + formValues?: Record; +} + +interface BuildClosedProviderEditorSessionInput { + previousClearProviderSecret?: boolean; +} + +export const buildAddProviderEditorSession = ({ + presetKey = 'openai', + presetBackendType, + presetBaseUrl, + presetModel, + presetModels = [], + apiFormat = 'openai', +}: BuildAddProviderEditorSessionInput): ProviderEditorSession => { + const editingProvider: ProviderEditorConfig = { + id: '', + type: presetBackendType, + name: '', + apiKey: '', + baseUrl: presetBaseUrl, + model: presetModel, + models: [...presetModels], + maxTokens: 4096, + temperature: 0.7, + presetKey, + }; + + return { + editingProvider, + formValues: { + ...editingProvider, + presetKey, + apiFormat, + }, + isEditing: true, + clearProviderSecret: false, + testStatus: 'idle', + }; +}; + +export const buildEditProviderEditorSession = ({ + provider, + formValues, +}: BuildEditProviderEditorSessionInput): ProviderEditorSession => ({ + editingProvider: provider, + formValues: formValues || { + ...provider, + models: provider.models || [], + presetKey: provider.presetKey, + apiFormat: provider.apiFormat || 'openai', + }, + isEditing: true, + clearProviderSecret: false, + testStatus: 'idle', +}); + +export const buildClosedProviderEditorSession = (_input?: BuildClosedProviderEditorSessionInput): ProviderEditorSession => ({ + editingProvider: null, + formValues: null, + isEditing: false, + clearProviderSecret: false, + testStatus: 'idle', +}); + diff --git a/frontend/src/utils/browserMockConnections.test.ts b/frontend/src/utils/browserMockConnections.test.ts new file mode 100644 index 0000000..10299c6 --- /dev/null +++ b/frontend/src/utils/browserMockConnections.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { duplicateBrowserMockConnection } from './browserMockConnections'; + +describe('duplicateBrowserMockConnection', () => { + it('rewrites config.id to match the duplicated top-level id', () => { + const duplicated = duplicateBrowserMockConnection({ + existing: { + id: 'conn-1', + name: 'Primary', + config: { + id: 'conn-1', + type: 'postgres', + }, + includeDatabases: ['appdb'], + }, + items: [], + nextId: 'conn-2', + }); + + expect(duplicated.id).toBe('conn-2'); + expect(duplicated.config.id).toBe('conn-2'); + expect(duplicated.name).toBe('Primary - 副本'); + expect(duplicated.includeDatabases).toEqual(['appdb']); + }); +}); diff --git a/frontend/src/utils/browserMockConnections.ts b/frontend/src/utils/browserMockConnections.ts new file mode 100644 index 0000000..402cd6f --- /dev/null +++ b/frontend/src/utils/browserMockConnections.ts @@ -0,0 +1,47 @@ +export const cloneBrowserMockValue = (value: T): T => { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } +}; + +export const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => { + if (String(nextValue ?? '') !== '') return true; + if (clearFlag) return false; + return !!existingFlag; +}; + +export const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => { + const baseName = String(rawName || '').trim() || '连接'; + const suffix = ' - 副本'; + const usedNames = new Set(items.map((item) => String(item?.name || '').trim())); + let candidate = `${baseName}${suffix}`; + let counter = 2; + while (usedNames.has(candidate)) { + candidate = `${baseName}${suffix} ${counter}`; + counter += 1; + } + return candidate; +}; + +interface DuplicateBrowserMockConnectionInput { + existing: any; + items: any[]; + nextId: string; +} + +export const duplicateBrowserMockConnection = ({ existing, items, nextId }: DuplicateBrowserMockConnectionInput) => { + const duplicated = cloneBrowserMockValue({ + ...existing, + id: nextId, + name: buildBrowserMockDuplicateName(existing?.name, items), + config: { + ...cloneBrowserMockValue(existing?.config), + id: nextId, + }, + includeDatabases: Array.isArray(existing?.includeDatabases) ? [...existing.includeDatabases] : undefined, + includeRedisDatabases: Array.isArray(existing?.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined, + }); + return duplicated; +}; diff --git a/frontend/src/utils/customConnectionDsn.test.ts b/frontend/src/utils/customConnectionDsn.test.ts new file mode 100644 index 0000000..8c35fb5 --- /dev/null +++ b/frontend/src/utils/customConnectionDsn.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldAllowBlankCustomDsn } from './customConnectionDsn'; + +describe('shouldAllowBlankCustomDsn', () => { + it('allows a blank DSN when editing a connection that already has a stored opaque DSN', () => { + expect(shouldAllowBlankCustomDsn({ + dsnInput: '', + hasStoredSecret: true, + clearStoredSecret: false, + })).toBe(true); + }); + + it('requires a new DSN when the user chooses to clear the stored opaque DSN', () => { + expect(shouldAllowBlankCustomDsn({ + dsnInput: '', + hasStoredSecret: true, + clearStoredSecret: true, + })).toBe(false); + }); + + it('requires a DSN for brand new custom connections', () => { + expect(shouldAllowBlankCustomDsn({ + dsnInput: '', + hasStoredSecret: false, + clearStoredSecret: false, + })).toBe(false); + }); + + it('accepts a newly entered DSN even when a stored secret already exists', () => { + expect(shouldAllowBlankCustomDsn({ + dsnInput: 'driver://demo', + hasStoredSecret: true, + clearStoredSecret: true, + })).toBe(true); + }); +}); diff --git a/frontend/src/utils/customConnectionDsn.ts b/frontend/src/utils/customConnectionDsn.ts new file mode 100644 index 0000000..58f92ed --- /dev/null +++ b/frontend/src/utils/customConnectionDsn.ts @@ -0,0 +1,27 @@ +export interface CustomConnectionDsnState { + dsnInput: unknown; + hasStoredSecret?: boolean; + clearStoredSecret?: boolean; +} + +export const getCustomConnectionDsnValidationMessage = ({ + dsnInput, + hasStoredSecret, + clearStoredSecret, +}: CustomConnectionDsnState): string | null => { + const dsnText = String(dsnInput ?? '').trim(); + if (dsnText !== '') { + return null; + } + if (hasStoredSecret && !clearStoredSecret) { + return null; + } + if (hasStoredSecret && clearStoredSecret) { + return '请输入新的连接字符串,或取消清除已保存 DSN'; + } + return '请输入连接字符串'; +}; + +export const shouldAllowBlankCustomDsn = (state: CustomConnectionDsnState): boolean => ( + getCustomConnectionDsnValidationMessage(state) === null +);