mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 14:31:35 +08:00
🐛 fix(connection-modal): 支持编辑态回填已保存密码并保持默认遮罩
- 编辑连接前主动拉取可编辑配置,恢复主密码与 SSH 等已保存密钥 - 支持 AI 供应商编辑态回填 API Key,并保持默认遮罩展示 - 修正 AI 设置长错误提示换行展示,避免测试连接报错被裁切 Refs #489
This commit is contained in:
@@ -493,6 +493,19 @@
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
right: auto !important;
|
||||
width: max-content;
|
||||
width: min(100%, 720px);
|
||||
max-width: calc(100% - 32px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message .ant-message-notice {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message .ant-message-notice-content {
|
||||
width: 100%;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./AISettingsModal.tsx', import.meta.url), 'utf8');
|
||||
const aiChatPanelCss = readFileSync(new URL('./AIChatPanel.css', import.meta.url), 'utf8');
|
||||
|
||||
describe('AISettingsModal edit password behavior', () => {
|
||||
it('loads editable provider details before opening the edit modal', () => {
|
||||
expect(source).toContain("typeof Service?.AIGetEditableProvider === 'function'");
|
||||
expect(source).toContain('await Service.AIGetEditableProvider(p.id)');
|
||||
});
|
||||
|
||||
it('keeps the prefilled api key masked by default', () => {
|
||||
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
|
||||
expect(source).toContain('visible: primaryPasswordVisible,');
|
||||
});
|
||||
|
||||
it('does not render the clear helper block anymore', () => {
|
||||
expect(source).not.toContain('当前已保存 API Key。留空表示继续沿用,输入新值表示替换。');
|
||||
expect(source).not.toContain('清除已保存 API Key');
|
||||
expect(source).not.toContain('留空表示继续沿用已保存密钥');
|
||||
});
|
||||
|
||||
it('renders in-modal test errors through the local message host', () => {
|
||||
expect(source).toContain('antdMessage.useMessage({ getContainer: () => modalBodyRef.current || document.body })');
|
||||
expect(source).toContain("void messageApi.error(`测试失败: ${res?.message || '未知错误'}`);");
|
||||
});
|
||||
|
||||
it('keeps long ai settings toast errors wrapped within the modal body', () => {
|
||||
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message {');
|
||||
expect(aiChatPanelCss).toContain('width: min(100%, 720px);');
|
||||
expect(aiChatPanelCss).toContain('max-width: calc(100% - 32px);');
|
||||
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message .ant-message-notice-content {');
|
||||
expect(aiChatPanelCss).toContain('white-space: normal;');
|
||||
expect(aiChatPanelCss).toContain('overflow-wrap: anywhere;');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Modal, Button, Input, Select, Form, 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';
|
||||
import {
|
||||
@@ -89,7 +89,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
|
||||
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
|
||||
const [clearProviderSecret, setClearProviderSecret] = useState(false);
|
||||
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const modalBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -107,7 +107,6 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const watchedType = Form.useWatch('type', form);
|
||||
const watchedPresetKey = Form.useWatch('presetKey', form);
|
||||
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
|
||||
const watchedApiKeyInput = Form.useWatch('apiKey', form);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
@@ -149,7 +148,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
setEditingProvider(session.editingProvider as AIProviderConfig | null);
|
||||
setIsEditing(session.isEditing);
|
||||
setTestStatus(session.testStatus);
|
||||
setClearProviderSecret(session.clearProviderSecret);
|
||||
setPrimaryPasswordVisible(false);
|
||||
form.resetFields();
|
||||
if (session.formValues) {
|
||||
form.setFieldsValue(session.formValues);
|
||||
@@ -182,24 +181,32 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditProvider = (p: AIProviderConfig) => {
|
||||
// 尝试根据 baseUrl 和 type 推断 preset
|
||||
const matchedPreset = matchProviderPreset(p);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: matchedPreset.backendType,
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: p.apiFormat,
|
||||
});
|
||||
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 handleEditProvider = async (p: AIProviderConfig) => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const editableProvider = typeof Service?.AIGetEditableProvider === 'function'
|
||||
? await Service.AIGetEditableProvider(p.id)
|
||||
: p;
|
||||
// 尝试根据 baseUrl 和 type 推断 preset
|
||||
const matchedPreset = matchProviderPreset(editableProvider);
|
||||
const resolvedTransport = resolvePresetTransport({
|
||||
presetBackendType: matchedPreset.backendType,
|
||||
presetFixedApiFormat: matchedPreset.fixedApiFormat,
|
||||
valuesApiFormat: editableProvider.apiFormat,
|
||||
});
|
||||
applyProviderEditorSession(buildEditProviderEditorSession({
|
||||
provider: { ...editableProvider, presetKey: matchedPreset.key } as any,
|
||||
formValues: {
|
||||
...editableProvider,
|
||||
type: resolvedTransport.type,
|
||||
models: editableProvider.models || [],
|
||||
presetKey: matchedPreset.key,
|
||||
apiFormat: resolvedTransport.apiFormat || editableProvider.apiFormat || 'openai',
|
||||
},
|
||||
}));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || '读取供应商配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
@@ -254,9 +261,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
hasSecret: editingProvider?.hasSecret,
|
||||
apiKeyInput: values.apiKey,
|
||||
clearSecret: clearProviderSecret,
|
||||
});
|
||||
const payload = {
|
||||
...editingProvider,
|
||||
@@ -330,12 +335,10 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
valuesApiFormat: values.apiFormat,
|
||||
});
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
hasSecret: editingProvider?.hasSecret,
|
||||
apiKeyInput: values.apiKey,
|
||||
clearSecret: clearProviderSecret,
|
||||
});
|
||||
if (secretDraft.mode === 'clear') {
|
||||
throw new Error('测试连接前请填写新的 API Key,或取消清除已保存密钥');
|
||||
throw new Error('测试连接前请填写 API Key');
|
||||
}
|
||||
const res = await Service?.AITestProvider?.({
|
||||
...editingProvider,
|
||||
@@ -544,25 +547,15 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div style={fieldLabelStyle}>
|
||||
<KeyOutlined style={{ fontSize: 14 }} /> 认证 & 连接
|
||||
</div>
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
|
||||
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || editingProvider?.id) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: 16 }}>
|
||||
<Input.Password placeholder="sk-... / 你的 API Key"
|
||||
size="middle"
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
|
||||
</Form.Item>
|
||||
{editingProvider?.hasSecret && (
|
||||
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
|
||||
当前已保存 API Key。留空表示继续沿用,输入新值表示替换。
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={clearProviderSecret}
|
||||
disabled={String(watchedApiKeyInput || '').trim() !== ''}
|
||||
onChange={(event) => setClearProviderSecret(event.target.checked)}
|
||||
>
|
||||
清除已保存 API Key
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
|
||||
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const source = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('ConnectionModal edit password behavior', () => {
|
||||
it('keeps the prefilled primary password masked by default', () => {
|
||||
expect(source).toContain('const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);');
|
||||
expect(source).not.toContain('setPrimaryPasswordVisible(String(config.password || "").trim() !== "")');
|
||||
expect(source).toContain('visible: primaryPasswordVisible,');
|
||||
});
|
||||
|
||||
it('does not render the primary-password clear helper block anymore', () => {
|
||||
expect(source).not.toContain('description:\n "当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。"');
|
||||
expect(source).not.toContain('description:\n "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。"');
|
||||
expect(source).toContain('String(config.password || "") === ""');
|
||||
});
|
||||
});
|
||||
@@ -193,6 +193,37 @@ const createEmptyConnectionSecretClearState =
|
||||
opaqueDSN: false,
|
||||
});
|
||||
|
||||
const resolveInitialSecretFieldValue = (
|
||||
initialValues: SavedConnection | null | undefined,
|
||||
fieldName: string,
|
||||
): string => {
|
||||
if (!initialValues) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const config = initialValues.config || ({} as ConnectionConfig);
|
||||
switch (fieldName) {
|
||||
case "password":
|
||||
return String(config.password || "");
|
||||
case "sshPassword":
|
||||
return String(config.ssh?.password || "");
|
||||
case "proxyPassword":
|
||||
return String(config.proxy?.password || "");
|
||||
case "httpTunnelPassword":
|
||||
return String(config.httpTunnel?.password || "");
|
||||
case "mysqlReplicaPassword":
|
||||
return String(config.mysqlReplicaPassword || "");
|
||||
case "mongoReplicaPassword":
|
||||
return String(config.mongoReplicaPassword || "");
|
||||
case "uri":
|
||||
return String(config.uri || "");
|
||||
case "dsn":
|
||||
return String(config.dsn || "");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultPortByType = (type: string) => {
|
||||
switch (type) {
|
||||
case "jvm":
|
||||
@@ -475,6 +506,7 @@ const ConnectionModal: React.FC<{
|
||||
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(
|
||||
createEmptyConnectionSecretClearState,
|
||||
);
|
||||
const [primaryPasswordVisible, setPrimaryPasswordVisible] = useState(false);
|
||||
const testInFlightRef = useRef(false);
|
||||
const testTimerRef = useRef<number | null>(null);
|
||||
const addConnection = useStore((state) => state.addConnection);
|
||||
@@ -649,7 +681,16 @@ const ConnectionModal: React.FC<{
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const draftValue = getFieldValue(fieldName);
|
||||
const hasDraftValue = String(draftValue ?? "") !== "";
|
||||
const initialSecretValue = resolveInitialSecretFieldValue(
|
||||
initialValues,
|
||||
fieldName,
|
||||
);
|
||||
const normalizedDraftValue = String(draftValue ?? "");
|
||||
const matchesInitialSecret =
|
||||
initialSecretValue !== "" &&
|
||||
normalizedDraftValue === initialSecretValue;
|
||||
const hasDraftValue =
|
||||
normalizedDraftValue !== "" && !matchesInitialSecret;
|
||||
const cardBorder = darkMode
|
||||
? "1px solid rgba(255,255,255,0.12)"
|
||||
: "1px solid rgba(16,24,40,0.08)";
|
||||
@@ -684,6 +725,9 @@ const ConnectionModal: React.FC<{
|
||||
disabled={hasDraftValue}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked;
|
||||
if (checked && matchesInitialSecret) {
|
||||
form.setFieldValue(fieldName, "");
|
||||
}
|
||||
setClearSecrets((prev) => ({ ...prev, [clearKey]: checked }));
|
||||
}}
|
||||
>
|
||||
@@ -2436,6 +2480,7 @@ const ConnectionModal: React.FC<{
|
||||
setCustomIconType(undefined);
|
||||
setCustomIconColor(undefined);
|
||||
setClearSecrets(createEmptyConnectionSecretClearState());
|
||||
setPrimaryPasswordVisible(false);
|
||||
setTypeSelectWarning(null);
|
||||
setDriverStatusLoaded(false);
|
||||
void refreshDriverStatus();
|
||||
@@ -2658,6 +2703,7 @@ const ConnectionModal: React.FC<{
|
||||
? config.jvm?.jmx?.password || ""
|
||||
: "",
|
||||
});
|
||||
setPrimaryPasswordVisible(false);
|
||||
setUseSSL(!!config.useSSL);
|
||||
setCustomIconType(initialValues.iconType);
|
||||
setCustomIconColor(initialValues.iconColor);
|
||||
@@ -2693,6 +2739,7 @@ const ConnectionModal: React.FC<{
|
||||
setActiveGroup(0);
|
||||
setActiveConfigSection("basic");
|
||||
setActiveNetworkConfig("ssl");
|
||||
setPrimaryPasswordVisible(false);
|
||||
}
|
||||
}
|
||||
}, [open, initialValues]);
|
||||
@@ -2712,7 +2759,10 @@ const ConnectionModal: React.FC<{
|
||||
const primaryDraft = resolveConnectionSecretDraft({
|
||||
hasSecret: initialValues?.hasPrimaryPassword,
|
||||
valueInput: config.password,
|
||||
clearSecret: clearSecrets.primaryPassword,
|
||||
clearSecret:
|
||||
clearSecrets.primaryPassword ||
|
||||
(initialValues?.hasPrimaryPassword === true &&
|
||||
String(config.password || "") === ""),
|
||||
forceClear: values.type === "mongodb" && values.savePassword === false,
|
||||
});
|
||||
const sshDraft = resolveConnectionSecretDraft({
|
||||
@@ -5504,6 +5554,10 @@ const ConnectionModal: React.FC<{
|
||||
<Form.Item name="password" label="密码 (可选)">
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder:
|
||||
@@ -5512,14 +5566,6 @@ const ConnectionModal: React.FC<{
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "password",
|
||||
clearKey: "primaryPassword",
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
clearLabel: "清除已保存密码",
|
||||
description:
|
||||
"当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。",
|
||||
})}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
@@ -5586,6 +5632,10 @@ const ConnectionModal: React.FC<{
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret:
|
||||
initialValues?.hasPrimaryPassword,
|
||||
@@ -5632,14 +5682,6 @@ const ConnectionModal: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "password",
|
||||
clearKey: "primaryPassword",
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
clearLabel: "清除已保存密码",
|
||||
description:
|
||||
"当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。",
|
||||
})}
|
||||
{dbType === "mongodb" && (
|
||||
<Form.Item
|
||||
name="savePassword"
|
||||
|
||||
Reference in New Issue
Block a user