/** * Admin 后台 - 统一设置页(报刊风) * * 合并原「数据源」「LLM 配置」「系统配置」三页: * 1. 数据源 — bzzoiro API Key + Key Ring 状态 * 2. LLM — 连接配置 + 使用统计 + 专家独立配置 * 3. 认证 — 修改密码 * 4. 系统 — 配置查看 + 修改指南 */ import { useEffect, useState, useCallback } from 'react' import { fetchSettings, updateSetting, clearSetting, testLLMConnection, fetchLLMUsageStats, fetchLLMModels, fetchKeyRingStatus, resetKeyRingCooldown, fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow, } from '../dal' import type { ScheduleItem } from '../dal' import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api' import type { LLMUsageStats, DataSourceSetting } from '../types' import type { KeyRingStatusResponse } from '../dal' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components' import SettingRow from '../SettingRow' import AgentLLMCard from '../AgentLLMCard' const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE'] const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL'] export default function SettingsPage() { const [allSettings, setAllSettings] = useState([]) const [settingsLoading, setSettingsLoading] = useState(true) const [editingKey, setEditingKey] = useState(null) const [busyKey, setBusyKey] = useState(null) const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null) // LLM const [llmStats, setLlmStats] = useState(null) const [llmLoading, setLlmLoading] = useState(true) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) // Key Ring const [keyRing, setKeyRing] = useState(null) const [ringLoading, setRingLoading] = useState(false) // 密码 const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null) const [currentPwd, setCurrentPwd] = useState('') const [newPwd, setNewPwd] = useState('') const [confirmPwd, setConfirmPwd] = useState('') const [pwdBusy, setPwdBusy] = useState(false) const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null) // 定时任务 const [schedules, setSchedules] = useState([]) const [schedulesLoading, setSchedulesLoading] = useState(true) const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null) const [scheduleBusyId, setScheduleBusyId] = useState(null) // ── 数据加载 ── const loadSettings = useCallback(async () => { setSettingsLoading(true) try { const all = await fetchSettings() setAllSettings(all) } catch { setAllSettings([]) } finally { setSettingsLoading(false) } }, []) const loadLlmStats = useCallback(async () => { setLlmLoading(true) try { setLlmStats(await fetchLLMUsageStats()) } catch { setLlmStats(null) } finally { setLlmLoading(false) } }, []) const loadKeyRing = useCallback(async () => { setRingLoading(true) try { setKeyRing(await fetchKeyRingStatus()) } catch { setKeyRing(null) } finally { setRingLoading(false) } }, []) useEffect(() => { loadSettings() loadLlmStats() loadKeyRing() fetchAuthState().then(s => setPasswordOrigin(s.password_origin ?? null)).catch(() => {}) fetchSchedules().then(setSchedules).catch(() => []).finally(() => setSchedulesLoading(false)) }, [loadSettings, loadLlmStats, loadKeyRing]) const dataSourceSettings = allSettings.filter(s => DATA_SOURCE_KEYS.includes(s.key)) const llmSettings = allSettings.filter(s => LLM_KEYS.includes(s.key)) // ── 操作 ── const handleSave = async (key: string, value: string) => { setBusyKey(key) setRowNotice(null) try { await updateSetting(key, value) setRowNotice({ key, ok: true, text: '已保存,立即生效' }) setEditingKey(null) await loadSettings() if (key === 'BZZOIRO_KEY') await loadKeyRing() } catch (err) { setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' }) } finally { setBusyKey(null) } } const handleClear = async (key: string) => { setBusyKey(key) setRowNotice(null) try { await clearSetting(key) setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' }) await loadSettings() } catch (err) { setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' }) } finally { setBusyKey(null) } } // ── 定时任务操作 ── const handleToggleSchedule = async (s: ScheduleItem) => { setScheduleBusyId(s.id) setScheduleNotice(null) try { await updateSchedule(s.id, { enabled: !s.enabled }) setScheduleNotice({ ok: true, text: `已${!s.enabled ? '启用' : '禁用'}任务「${s.id}」` }) setSchedules(prev => prev.map(x => x.id === s.id ? { ...x, enabled: !x.enabled } : x)) } catch { setScheduleNotice({ ok: false, text: '操作失败' }) } finally { setScheduleBusyId(null) } } const handleRunSchedule = async (s: ScheduleItem) => { setScheduleBusyId(s.id) setScheduleNotice(null) try { await runScheduleNow(s.id) setScheduleNotice({ ok: true, text: `任务「${s.id}」已启动,请在日志页查看进度` }) } catch { setScheduleNotice({ ok: false, text: '启动失败' }) } finally { setScheduleBusyId(null) } } const handleDeleteSchedule = async (s: ScheduleItem) => { setScheduleBusyId(s.id) setScheduleNotice(null) try { await deleteSchedule(s.id) setScheduleNotice({ ok: true, text: `已删除任务「${s.id}」` }) setSchedules(prev => prev.filter(x => x.id !== s.id)) } catch { setScheduleNotice({ ok: false, text: '删除失败' }) } finally { setScheduleBusyId(null) } } const detectLLMModels = useCallback(async (): Promise => { const r = await fetchLLMModels() if (!r.ok) throw new Error(r.detail) return r.models }, []) const handleTest = async () => { setTesting(true) setTestResult(null) try { await testLLMConnection() setTestResult({ success: true, message: 'LLM 连接测试成功' }) } catch (err: unknown) { setTestResult({ success: false, message: err instanceof Error ? err.message : 'LLM 连接测试失败' }) } finally { setTesting(false) } } const handleResetCooldown = async () => { if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return try { const res = await resetKeyRingCooldown() setKeyRing(res.stats) setRowNotice({ key: "__ring", ok: true, text: res.message }) } catch (err) { setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' }) } } const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault() setPwdNotice(null) if (newPwd !== confirmPwd) { setPwdNotice({ ok: false, text: '两次输入的新密码不一致' }) return } setPwdBusy(true) try { const res = await changePassword(currentPwd, newPwd) setPwdNotice({ ok: true, text: res.message }) setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500) } catch (err) { setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' }) } finally { setPwdBusy(false) } } return (
{/* ── 1. 数据源 ── */}

数据源

{settingsLoading ? <> 加载中 : '刷新'}} /> {settingsLoading ? (
{dataSourceSettings.map((_, i) => )}
) : ( dataSourceSettings.map(setting => ( { setEditingKey(setting.key); setRowNotice(null) }} onCancel={() => setEditingKey(null)} onSave={v => handleSave(setting.key, v)} onClear={() => handleClear(setting.key)} /> )) )} {rowNotice && !rowNotice.key.startsWith('__') && dataSourceSettings.some(s => s.key === rowNotice.key) && (
)}
{/* Key Ring */} 重置冷却} /> {keyRing && keyRing.total > 0 ? (
{keyRing.keys.map((k, i) => { const isBlocked = k.blocked_remaining > 0 return (
{k.masked} {i === keyRing.active_index && 当前}
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
) })}
) : (

暂无 key 配置

)}
{/* ── 2. LLM ── */}

大语言模型

{settingsLoading ? <> 加载中 : '刷新'}} /> {settingsLoading ? (
{llmSettings.map((_, i) => )}
) : ( llmSettings.map(setting => ( { setEditingKey(setting.key); setRowNotice(null) }} onCancel={() => setEditingKey(null)} onSave={v => handleSave(setting.key, v)} onClear={() => handleClear(setting.key)} detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined} /> )) )} {rowNotice && !rowNotice.key.startsWith('__') && llmSettings.some(s => s.key === rowNotice.key) && (
)} {testResult && (
)}

测试会真实调用一次 LLM 预测,产生费用。

{llmLoading ? <> 加载中 : '刷新'}} /> {llmLoading ? (
) : llmStats ? (
{llmStats.total_predictions}
总预测数
{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
平均延迟
{llmStats.success_rate.toFixed(0)}%
有效率
) : (

暂无使用统计数据

)}
{/* ── 3. 认证 ── */}

登录认证

{passwordOrigin && (

当前密码来源: {passwordOrigin === 'db' ? 数据库(scrypt 哈希) : passwordOrigin === 'env' ? .env 初始值 : 未配置}

)}
setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" />
setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
setConfirmPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
{pwdNotice && }

修改成功后会自动退出登录。

{/* ── 4. 定时任务 ── */}

定时任务

{ createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false }) .then(() => fetchSchedules().then(setSchedules)) }} className="btn btn-sm" > + 新建 } /> {scheduleNotice && (
setScheduleNotice(null)} />
)} {schedulesLoading ? ( ) : schedules.length === 0 ? (

暂无定时任务,点击右上角「+ 新建」创建

) : (
{schedules.map(s => (
{s.id} {s.task}

{s.cron}

{s.last_run_at && (

上次: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })} {s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}

)}
))}
)}
) }