/** * SearXNGSettings — SearXNG 元搜索配置 Tab * * 从 SettingsModal.tsx 提取(v0.4.1 拆分)。 * 功能:SearXNG 实例配置(12 项)+ 连接测试。 * * v0.6.4 P3-7 重构(批量草稿模型统一):原先 12 个字段各自经 useConfig * "逐键实时落库" —— 数字输入每敲一个字符就触发一次 IPC 写 + reloadAdapter 副作用, * 且 SearXNG 的数字字段无范围 guard 会把中间态写进配置。现改为与 LLMSettings 同一 * 草稿范式:mount 时一次读入 → 本地草稿编辑(dirty 标记)→ 显式"保存"批量提交。 * 启用开关保持即时生效(它是功能总开关,语义上属于立即动作而非表单字段)。 */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, TextField, Select, MenuItem, Stack, Typography, Divider, Chip, Switch, Alert, Box, FormControlLabel, InputLabel, FormControl, IconButton, CircularProgress, } from '@mui/material'; import { Eye, EyeOff, Save } from 'lucide-react'; interface DraftConfig { url: string; engines: string; language: string; safesearch: number; time_range: string; max_results: number; auth_key: string; auth_type: string; format: string; fetch_count: number; fetch_mode: string; } type DraftKey = keyof DraftConfig; const DRAFT_DEFAULTS: DraftConfig = { url: '', engines: '', language: 'zh-CN', safesearch: 1, time_range: '', max_results: 0, auth_key: '', auth_type: 'bearer', format: 'json', fetch_count: 0, fetch_mode: 'sequential', }; /** 配置键 → 草稿字段的映射与读取类型转换 */ const KEY_MAP: Array<{ key: string; field: DraftKey; type: 'string' | 'number' }> = [ { key: 'searxng.url', field: 'url', type: 'string' }, { key: 'searxng.engines', field: 'engines', type: 'string' }, { key: 'searxng.language', field: 'language', type: 'string' }, { key: 'searxng.safesearch', field: 'safesearch', type: 'number' }, { key: 'searxng.time_range', field: 'time_range', type: 'string' }, { key: 'searxng.max_results', field: 'max_results', type: 'number' }, { key: 'searxng.auth_key', field: 'auth_key', type: 'string' }, { key: 'searxng.auth_type', field: 'auth_type', type: 'string' }, { key: 'searxng.format', field: 'format', type: 'string' }, { key: 'searxng.fetch_count', field: 'fetch_count', type: 'number' }, { key: 'searxng.fetch_mode', field: 'fetch_mode', type: 'string' }, ]; export function SearXNGSettings() { const [enabled, setEnabledState] = useState(false); const [enabledLoaded, setEnabledLoaded] = useState(false); const [draft, setDraft] = useState(DRAFT_DEFAULTS); /** 首次加载完成(或最近一次成功保存)时的草稿快照 —— dirty 判定基准 */ const [baseline, setBaseline] = useState(null); const [loaded, setLoaded] = useState(false); const [saving, setSaving] = useState(false); const [savedNotice, setSavedNotice] = useState(false); const [showKey, setShowKey] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); // ===== mount:一次性读入全部配置(12 次 IPC 并行,替代原逐键写路径)===== useEffect(() => { let cancelled = false; void (async () => { const entries = await Promise.all( KEY_MAP.map(async ({ key }) => ({ key, value: await window.metona?.config?.get(key) })), ); if (cancelled) return; const next = { ...DRAFT_DEFAULTS }; for (const entry of entries) { const spec = KEY_MAP.find((k) => k.key === entry.key)!; if (entry.value === null || entry.value === undefined) continue; (next[spec.field] as unknown) = spec.type === 'number' ? Number(entry.value) : String(entry.value); } setDraft(next); // 精确 dirty 基准:以加载时快照对比,而非"任何交互即 dirty" setBaseline(next); setLoaded(true); })(); void window.metona?.config ?.get('searxng.enabled') .then((v) => { if (!cancelled) { setEnabledState(v === true); setEnabledLoaded(true); } }) .catch(() => setEnabledLoaded(true)); return () => { cancelled = true; }; }, []); const updateField = useCallback((field: K, value: DraftConfig[K]) => { setSavedNotice(false); setDraft((prev) => ({ ...prev, [field]: value })); }, []); // v0.6.4 P3-7: dirty 精确判定 —— 当前草稿与基线快照逐字段比较 const dirty = useMemo(() => { if (!loaded || !baseline) return false; return KEY_MAP.some(({ field }) => draft[field] !== baseline[field]); }, [draft, baseline, loaded]); const handleSave = async () => { setSaving(true); try { await window.metona?.config?.setBatch( KEY_MAP.map(({ key, field }) => ({ key, value: draft[field] as string | number })), ); setBaseline(draft); setSavedNotice(true); import('@metona-team/metona-toast') .then((mod) => mod.default.success('SearXNG 配置已保存')) .catch(() => {}); } catch (err) { console.error('[SearXNGSettings]', err); import('@metona-team/metona-toast') .then((mod) => mod.default.error('保存失败')) .catch(() => {}); } setSaving(false); }; // ===== 数字输入安全钳制(v0.6.4: 原 useConfig 无 guard 导致中间态逐键落库)===== const clampNumber = (value: number, min: number, max: number): number => Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : min; const handleToggleEnabled = async (checked: boolean): Promise => { // 总开关保持即时落库(立即动作语义),并向前端状态同步 setEnabledState(checked); try { await window.metona?.config?.set('searxng.enabled', checked); } catch (err) { console.error('[SearXNGSettings] toggle failed:', err); setEnabledState(!checked); import('@metona-team/metona-toast') .then((mod) => mod.default.error('开关保存失败')) .catch(() => {}); } }; const urlError = !!draft.url && !/^https?:\/\//.test(draft.url); const handleTest = async () => { if (!draft.url.trim() || urlError) return; setTesting(true); setTestResult(null); try { const result = await window.metona?.searxng?.testConnection( draft.url.trim(), draft.auth_key, draft.auth_type, ); if (result?.success) { setTestResult({ success: true, message: `连接成功(${result.latencyMs}ms)` }); } else { setTestResult({ success: false, message: result?.error || `连接失败(HTTP ${result?.statusCode})`, }); } } catch (e) { setTestResult({ success: false, message: (e as Error).message }); } setTesting(false); }; if (!enabledLoaded || !loaded) { return ( 正在读取配置... ); } return ( {/* 标题 + 状态徽章 */} SearXNG 元搜索 SearXNG 是开源元搜索引擎,支持 70+ 搜索引擎聚合。启用后替代内置四引擎搜索通道,未启用时回退到 Bing + 百度 + 搜狗 + 360 搜索。 {/* 启用开关(即时生效的总开关) */} void handleToggleEnabled(e.target.checked)} size="small" slotProps={{ input: { 'aria-label': '启用 SearXNG' } }} /> } label={启用 SearXNG} /> {/* API 地址 + 连接测试 */} updateField('url', e.target.value)} placeholder="如 https://searxng.example.com" error={urlError} helperText={ urlError ? '需以 http:// 或 https:// 开头' : '实例根地址(不含 /search 路径)' } sx={{ flex: 1 }} /> {/* 测试结果 */} {testResult && ( {testResult.message} )} {/* 搜索引擎 */} updateField('engines', e.target.value)} placeholder="如 google,bing,duckduckgo(留空使用实例默认)" /> {/* 语言 + 安全搜索 */} 语言 安全搜索 {/* 时间范围 + 返回格式 */} 时间范围 返回格式 {/* 最大结果数 + 自动抓取条数 */} updateField('max_results', clampNumber(Number(e.target.value), 0, 50)) } placeholder="0 表示使用默认" slotProps={{ htmlInput: { min: 0, max: 50 } }} /> updateField('fetch_count', clampNumber(Number(e.target.value), 0, 8)) } placeholder="0 表示由 AI 决定" slotProps={{ htmlInput: { min: 0, max: 8 } }} /> {/* 抓取类型 */} 抓取类型 {/* 认证设置 */} 认证设置 认证类型 updateField('auth_key', e.target.value)} placeholder={draft.auth_type === 'bearer' ? '访问令牌原值' : 'username:password'} slotProps={{ input: { endAdornment: ( setShowKey(!showKey)} aria-label={showKey ? '隐藏密钥' : '显示密钥'} > {showKey ? : } ), }, }} /> {draft.auth_type === 'bearer' ? 'Bearer: 直接填写令牌原值,原样透传到 Authorization 头。建议配合 HTTPS 使用。' : 'Basic: 填写 username:password 明文串,系统自动 Base64 编码。必须配合 HTTPS 使用。'} {/* 保存区(批量草稿提交 + 变更提示) */} {dirty && ( 有未保存的修改 )} {!dirty && savedNotice && ( 已保存 )} ); }