Files
Profeto/frontend/src/admin/pages/Settings.tsx
T
shangfangjianandnew-provider/LongCat-2.0 < 7a4e61ebc4 定时任务操作添加点击反馈和文案提示
- 启用/禁用/立即执行/删除按钮添加 loading 状态(Spinner)
- 操作成功后显示绿色提示(如「已启用任务 xxx」)
- 操作失败后显示红色错误提示
- 按钮禁用期间防止重复点击

Co-Authored-By: new-provider/LongCat-2.0 <<EMAIL>>
2026-09-21 01:42:17 +08:00

497 lines
21 KiB
TypeScript

/**
* 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<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
// LLM
const [llmStats, setLlmStats] = useState<LLMUsageStats | null>(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<KeyRingStatusResponse | null>(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<ScheduleItem[]>([])
const [schedulesLoading, setSchedulesLoading] = useState(true)
const [scheduleNotice, setScheduleNotice] = useState<{ ok: boolean; text: string } | null>(null)
const [scheduleBusyId, setScheduleBusyId] = useState<string | null>(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<string[]> => {
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 (
<div className="space-y-8">
<SectionHeader
title="系统设置"
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
/>
{/* ── 1. 数据源 ── */}
<section>
<h2 className="section-head mb-3">数据源</h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="Bzzoiro API"
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-3">{dataSourceSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
dataSourceSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { 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) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
</CardBody>
</Card>
{/* Key Ring */}
<Card>
<CardHeader
title="API Key 轮换环"
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
action={<button onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline">重置冷却</button>}
/>
<CardBody>
{keyRing && keyRing.total > 0 ? (
<div className="space-y-2">
{keyRing.keys.map((k, i) => {
const isBlocked = k.blocked_remaining > 0
return (
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
</div>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-ink-400">暂无 key 配置</p>
)}
</CardBody>
</Card>
</div>
</section>
{/* ── 2. LLM ── */}
<section>
<h2 className="section-head mb-3">大语言模型</h2>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader
title="连接配置"
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
/>
<CardBody>
{settingsLoading ? (
<div className="space-y-3">{llmSettings.map((_, i) => <SkeletonBlock key={i} className="h-9 w-full" />)}</div>
) : (
llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { 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) && (
<div className="mt-3"><Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} /></div>
)}
{testResult && (
<div className="mt-4">
<Alert kind={testResult.success ? 'ok' : 'error'} title={testResult.success ? '连接正常' : '连接失败'} message={testResult.success ? undefined : testResult.message} />
</div>
)}
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
{testing ? <><Spinner /> 测试中</> : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400">测试会真实调用一次 LLM 预测,产生费用。</p>
</CardBody>
</Card>
<Card>
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<button onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{llmLoading ? <><Spinner /> 加载中</> : '刷新'}</button>} />
<CardBody>
{llmLoading ? (
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
) : llmStats ? (
<div className="grid grid-cols-3 gap-4">
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.total_predictions}</div>
<div className="mt-1 text-2xs text-ink-400">总预测数</div>
</div>
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
<div className="mt-1 text-2xs text-ink-400">平均延迟</div>
</div>
<div className="border-t-2 border-press pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-press">{llmStats.success_rate.toFixed(0)}%</div>
<div className="mt-1 text-2xs text-ink-400">有效率</div>
</div>
</div>
) : (
<p className="py-6 text-center text-xs text-ink-400">暂无使用统计数据</p>
)}
</CardBody>
</Card>
</div>
<div className="mt-6">
<AgentLLMCard />
</div>
</section>
{/* ── 3. 认证 ── */}
<section>
<h2 className="section-head mb-3">登录认证</h2>
<Card>
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
<CardBody>
{passwordOrigin && (
<p className="mb-3 flex items-center gap-2 text-2xs text-ink-500">
当前密码来源:
{passwordOrigin === 'db' ? <Badge status="success">数据库(scrypt 哈希)</Badge>
: passwordOrigin === 'env' ? <Badge status="info">.env 初始值</Badge>
: <Badge status="error">未配置</Badge>}
</p>
)}
<form onSubmit={handleChangePassword} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-2xs text-ink-500">当前密码</label>
<input type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="field w-full" />
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500">新密码(至少 8 )</label>
<input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
</div>
<div>
<label className="mb-1 block text-2xs text-ink-500">确认新密码</label>
<input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="field w-full" />
</div>
</div>
{pwdNotice && <Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />}
<div className="flex items-center justify-between gap-2">
<p className="text-2xs text-ink-400">修改成功后会自动退出登录。</p>
<button type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd} className="btn btn-solid btn-sm flex-shrink-0">
{pwdBusy ? <><Spinner /> 修改中</> : '修改密码'}
</button>
</div>
</form>
</CardBody>
</Card>
</section>
{/* ── 4. 定时任务 ── */}
<section>
<h2 className="section-head mb-3">定时任务</h2>
<Card>
<CardHeader
title="采集调度"
description="配置 cron 表达式定时触发采集任务"
action={
<button
onClick={() => {
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
.then(() => fetchSchedules().then(setSchedules))
}}
className="btn btn-sm"
>
+ 新建
</button>
}
/>
<CardBody>
{scheduleNotice && (
<div className="mb-3">
<Alert kind={scheduleNotice.ok ? 'ok' : 'error'} title={scheduleNotice.text} onClose={() => setScheduleNotice(null)} />
</div>
)}
{schedulesLoading ? (
<SkeletonBlock className="h-10 w-full" />
) : schedules.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">暂无定时任务,点击右上角「+ 新建」创建</p>
) : (
<div className="space-y-2">
{schedules.map(s => (
<div key={s.id} className="flex flex-col gap-2 border-b border-ink-100 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-emerald-500' : 'bg-ink-300'}`} />
<span className="text-xs font-medium text-ink-800">{s.id}</span>
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
</div>
<p className="font-mono text-2xs text-ink-500">{s.cron}</p>
{s.last_run_at && (
<p className="text-2xs text-ink-400">
上次: {new Date(s.last_run_at).toLocaleString('zh-CN', { hour12: false })}
{s.last_status === 'success' ? ' ✓' : s.last_status === 'failed' ? ' ✗' : ''}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleRunSchedule(s)}
disabled={scheduleBusyId === s.id}
className="btn btn-sm"
>
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
</button>
<button
onClick={() => handleToggleSchedule(s)}
disabled={scheduleBusyId === s.id}
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
>
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
</button>
<button
onClick={() => handleDeleteSchedule(s)}
disabled={scheduleBusyId === s.id}
className="btn btn-sm btn-danger"
>
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
</button>
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
</section>
</div>
)
}