🐛 fix(connection-modal): 支持编辑态回填已保存密码并保持默认遮罩

- 编辑连接前主动拉取可编辑配置,恢复主密码与 SSH 等已保存密钥
- 支持 AI 供应商编辑态回填 API Key,并保持默认遮罩展示
- 修正 AI 设置长错误提示换行展示,避免测试连接报错被裁切

Refs #489
This commit is contained in:
Syngnat
2026-05-30 17:25:58 +08:00
parent ebda018e13
commit fdcbadf918
19 changed files with 453 additions and 130 deletions

View File

@@ -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;
}

View File

@@ -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;');
});
});

View File

@@ -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 }}>

View File

@@ -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 || "") === ""');
});
});

View File

@@ -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"