fix: AI 配置服务商下拉选不中 + 先测试通过才能保存(参考 aiops)

- 根因:settings 刷新(保存其它配置后 invalidate)触发的表单初始化
  useEffect 会把正在编辑的服务商/端点/密钥打回已保存值,表现为下拉
  "闪退选不中"。改为 useRef 守卫仅首次加载数据时初始化一次,
  后续刷新不再覆盖用户编辑(同时修复 lark/ldap 表单同类隐患)
- 先测试才能保存:启用 AI 后「保存」按钮在「测试连接」通过前禁用
  (title 提示),任何字段修改重置测试状态;通过后显示绿色
  "✓ 测试通过,可保存"徽标;未启用 AI 不受门槛限制
This commit is contained in:
Logan.Li committed 2026-10-27 15:21:54 +08:00
1 parent b6b53c42a7
commit 2040cd042a
1 file changed
+35 -10
+35 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
Palette, Monitor, Moon, Sun, Palette, Monitor, Moon, Sun,
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight
@@ -89,6 +89,12 @@ export default function SystemSettings() {
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications); const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' }); const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
const [llmProvider, setLlmProvider] = useState('custom'); const [llmProvider, setLlmProvider] = useState('custom');
// 先测试才能保存:null=未测 true=通过;任何表单修改都会重置
const [llmTestOk, setLlmTestOk] = useState<boolean | null>(null);
const updateLlm = (patch: Partial<typeof llm>) => {
setLlm((prev) => ({ ...prev, ...patch }));
setLlmTestOk(null);
};
const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' }); const [lark, setLark] = useState({ enabled: false, webhook: '', secret: '' });
const [ldap, setLdap] = useState({ const [ldap, setLdap] = useState({
enabled: false, enabled: false,
@@ -109,9 +115,12 @@ export default function SystemSettings() {
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
// 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态) // 用后端数据初始化表单:仅在首次拿到数据时执行一次,
// 避免后续 settings 刷新(保存其它配置后 invalidate)把用户正在编辑的表单打回已保存值
const initedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings || initedRef.current) return;
initedRef.current = true;
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme)); setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications)); setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
setLlm((prev) => ({ setLlm((prev) => ({
@@ -238,6 +247,7 @@ export default function SystemSettings() {
llmApiKey: llm.apiKey || undefined, llmApiKey: llm.apiKey || undefined,
}), }),
onSuccess: (res) => { onSuccess: (res) => {
setLlmTestOk(!!res.success);
if (res.success) toast.success('AI 连接成功', { description: res.message }); if (res.success) toast.success('AI 连接成功', { description: res.message });
else toast.error('AI 连接失败', { description: res.message }); else toast.error('AI 连接失败', { description: res.message });
}, },
@@ -534,7 +544,7 @@ export default function SystemSettings() {
</div> </div>
<div className="p-5 space-y-4"> <div className="p-5 space-y-4">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={llm.enabled} onChange={(e) => setLlm({ ...llm, enabled: e.target.checked })} /> <input type="checkbox" checked={llm.enabled} onChange={(e) => updateLlm({ enabled: e.target.checked })} />
<span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span> <span className="text-[12px]" style={{ color: 'var(--text-primary)' }}>启用 AI(OpenAI 兼容接口:DeepSeek / GLM / Qwen 等)</span>
</label> </label>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -547,7 +557,7 @@ export default function SystemSettings() {
const preset = LLM_PROVIDERS.find((p) => p.id === id); const preset = LLM_PROVIDERS.find((p) => p.id === id);
if (preset && id !== 'custom') { if (preset && id !== 'custom') {
// 非 custom:自动填入 endpoint/model(用户仍可修改) // 非 custom:自动填入 endpoint/model(用户仍可修改)
setLlm((prev) => ({ ...prev, endpoint: preset.endpoint, model: preset.model })); updateLlm({ endpoint: preset.endpoint, model: preset.model });
} }
}} }}
className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none"
@@ -562,7 +572,7 @@ export default function SystemSettings() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label> <label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<input value={llm.endpoint} onChange={(e) => setLlm({ ...llm, endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="flex-1 min-w-0 px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /> <input value={llm.endpoint} onChange={(e) => updateLlm({ endpoint: e.target.value })} placeholder="https://api.deepseek.com/v1" className="flex-1 min-w-0 px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
{!!LLM_PROVIDERS.find((p) => p.id === llmProvider)?.keyUrl && ( {!!LLM_PROVIDERS.find((p) => p.id === llmProvider)?.keyUrl && (
<a <a
href={LLM_PROVIDERS.find((p) => p.id === llmProvider)!.keyUrl} href={LLM_PROVIDERS.find((p) => p.id === llmProvider)!.keyUrl}
@@ -579,22 +589,37 @@ export default function SystemSettings() {
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label> <label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>模型</label>
<input value={llm.model} onChange={(e) => setLlm({ ...llm, model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /> <input value={llm.model} onChange={(e) => updateLlm({ model: e.target.value })} placeholder="deepseek-chat" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div> </div>
<div className="space-y-1.5 col-span-2"> <div className="space-y-1.5 col-span-2">
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label> <label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Key</label>
<input type="password" value={llm.apiKey} onChange={(e) => setLlm({ ...llm, apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /> <input type="password" value={llm.apiKey} onChange={(e) => updateLlm({ apiKey: e.target.value })} placeholder={settings?.llmApiKeySet ? '已配置,留空不修改' : 'sk-...'} className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none font-mono" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
</div> </div>
</div> </div>
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p> <p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button onClick={() => saveLlmMutation.mutate()} disabled={saveLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}> <button
onClick={() => saveLlmMutation.mutate()}
disabled={saveLlmMutation.isPending || (llm.enabled && llmTestOk !== true)}
title={llm.enabled && llmTestOk !== true ? '请先「测试连接」通过后再保存' : undefined}
className="px-3 py-1 text-[12px] rounded-md text-white disabled:opacity-50"
style={{ background: 'var(--accent-blue)' }}
>
{saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'} {saveLlmMutation.isPending ? '保存中…' : '保存 AI 配置'}
</button> </button>
<button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}> <button onClick={() => testLlmMutation.mutate()} disabled={testLlmMutation.isPending} className="px-3 py-1 text-[12px] rounded-md border disabled:opacity-60" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
{testLlmMutation.isPending ? '测试中…' : '测试连接(按当前表单直测)'} {testLlmMutation.isPending ? '测试中…' : '测试连接'}
</button> </button>
{llm.enabled && llmTestOk === true && (
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: '#dafbe1', color: '#1a7f37' }}>✓ 测试通过,可保存</span>
)}
{llm.enabled && llmTestOk === false && (
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: '#ffebe9', color: '#cf222e' }}>测试未通过,请检查配置</span>
)}
</div> </div>
{llm.enabled && llmTestOk !== true && (
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>启用 AI 后需先「测试连接」通过才能保存,避免保存不可用配置</p>
)}
</div> </div>
</div> </div>
</> </>