fix: AI 配置服务商下拉选不中 + 先测试通过才能保存(参考 aiops)
- 根因:settings 刷新(保存其它配置后 invalidate)触发的表单初始化 useEffect 会把正在编辑的服务商/端点/密钥打回已保存值,表现为下拉 "闪退选不中"。改为 useRef 守卫仅首次加载数据时初始化一次, 后续刷新不再覆盖用户编辑(同时修复 lark/ldap 表单同类隐患) - 先测试才能保存:启用 AI 后「保存」按钮在「测试连接」通过前禁用 (title 提示),任何字段修改重置测试状态;通过后显示绿色 "✓ 测试通过,可保存"徽标;未启用 AI 不受门槛限制
This commit is contained in:
1 parent
b6b53c42a7
commit
2040cd042a
1 file changed
+35
-10
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Palette, Monitor, Moon, Sun,
|
||||
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2, ExternalLink, ChevronDown, ChevronRight
|
||||
@@ -89,6 +89,12 @@ export default function SystemSettings() {
|
||||
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||
const [llm, setLlm] = useState({ enabled: false, endpoint: '', model: '', apiKey: '' });
|
||||
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 [ldap, setLdap] = useState({
|
||||
enabled: false,
|
||||
@@ -109,9 +115,12 @@ export default function SystemSettings() {
|
||||
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||
|
||||
// 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态)
|
||||
// 用后端数据初始化表单:仅在首次拿到数据时执行一次,
|
||||
// 避免后续 settings 刷新(保存其它配置后 invalidate)把用户正在编辑的表单打回已保存值
|
||||
const initedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
if (!settings || initedRef.current) return;
|
||||
initedRef.current = true;
|
||||
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
||||
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
||||
setLlm((prev) => ({
|
||||
@@ -238,6 +247,7 @@ export default function SystemSettings() {
|
||||
llmApiKey: llm.apiKey || undefined,
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
setLlmTestOk(!!res.success);
|
||||
if (res.success) toast.success('AI 连接成功', { description: res.message });
|
||||
else toast.error('AI 连接失败', { description: res.message });
|
||||
},
|
||||
@@ -534,7 +544,7 @@ export default function SystemSettings() {
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<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>
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
@@ -547,7 +557,7 @@ export default function SystemSettings() {
|
||||
const preset = LLM_PROVIDERS.find((p) => p.id === id);
|
||||
if (preset && id !== 'custom') {
|
||||
// 非 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"
|
||||
@@ -562,7 +572,7 @@ export default function SystemSettings() {
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>接口地址</label>
|
||||
<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 && (
|
||||
<a
|
||||
href={LLM_PROVIDERS.find((p) => p.id === llmProvider)!.keyUrl}
|
||||
@@ -579,22 +589,37 @@ export default function SystemSettings() {
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<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 className="space-y-1.5 col-span-2">
|
||||
<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>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未启用 AI 时,智能助手自动降级为本地规则模式(按服务名匹配,仍可正常发布)</p>
|
||||
<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 配置'}
|
||||
</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)' }}>
|
||||
{testLlmMutation.isPending ? '测试中…' : '测试连接(按当前表单直测)'}
|
||||
{testLlmMutation.isPending ? '测试中…' : '测试连接'}
|
||||
</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>
|
||||
{llm.enabled && llmTestOk !== true && (
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>启用 AI 后需先「测试连接」通过才能保存,避免保存不可用配置</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user