feat: 管理界面优化 — 移动端自适应 + 数据源 + LLM 配置
移动端自适应: - AdminLayout: 汉堡菜单 + 可折叠侧边栏 + 遮罩层 + ESC 关闭 - 所有页面响应式布局 (grid-cols-1 sm:grid-cols-2 lg:grid-cols-4) - 触摸友好按钮 (min-h-[44px]) - 表格移动端卡片视图 新增页面: - DataSources: 数据源状态 + API Key 脱敏 + 测试连接 - LLMConfig: LLM 配置 + 测试连接 + 使用统计 + 最近预测 增强功能: - Config: 配置列表 + 修改指南 + 快捷导航 - types: 新增 DataSourceStatus, LLMUsageStats 等类型 - dal: 新增 testDataSource, fetchDataSourceStatuses, testLLMConnection 等
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Admin 后台 - LLM 配置管理页面
|
||||
*
|
||||
* 功能:
|
||||
* - 显示当前 LLM 配置(provider, model, base_url)
|
||||
* - 测试 LLM 连接(调用 /predict 测试)
|
||||
* - 显示 LLM 使用统计(预测次数、平均延迟、成功率)
|
||||
* - 模型切换(显示可用模型列表)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { testLLMConnection, fetchLLMUsageStats } from '../dal'
|
||||
import type { LLMUsageStats } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
|
||||
|
||||
// 可用模型列表
|
||||
const AVAILABLE_MODELS = [
|
||||
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'openai', description: '最强大模型,适合复杂分析' },
|
||||
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'openai', description: '快速经济,适合批量预测' },
|
||||
{ id: 'claude-3-5-sonnet', label: 'Claude 3.5 Sonnet', provider: 'anthropic', description: '长上下文分析能力强' },
|
||||
{ id: 'deepseek-chat', label: 'DeepSeek V3', provider: 'deepseek', description: '高性价比中文优化' },
|
||||
]
|
||||
|
||||
export default function LLMConfigPage() {
|
||||
const [stats, setStats] = useState<LLMUsageStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
|
||||
// 当前配置(模拟,后端暂无配置端点)
|
||||
const currentConfig = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4o',
|
||||
base_url: 'https://api.openai.com/v1',
|
||||
api_key_configured: true,
|
||||
api_key_masked: 'sk-****...****abcd',
|
||||
}
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchLLMUsageStats()
|
||||
setStats(data)
|
||||
} catch {
|
||||
setStats(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadStats() }, [loadStats])
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
await testLLMConnection()
|
||||
setTestResult({ success: true, message: 'LLM 连接测试成功' })
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'LLM 连接测试失败'
|
||||
setTestResult({ success: false, message: msg })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="LLM 配置"
|
||||
description="管理大语言模型连接与使用统计"
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* 当前配置 */}
|
||||
<Card>
|
||||
<CardHeader title="当前配置" />
|
||||
<CardBody className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||
<span className="text-xs text-gray-500">提供商</span>
|
||||
<Badge status="info">{currentConfig.provider}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||
<span className="text-xs text-gray-500">模型</span>
|
||||
<span className="text-sm text-gray-200">{currentConfig.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||
<span className="text-xs text-gray-500">API 地址</span>
|
||||
<span className="text-xs font-mono text-gray-400">{currentConfig.base_url}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-gray-800 pb-3">
|
||||
<span className="text-xs text-gray-500">API Key</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-400">{currentConfig.api_key_masked}</span>
|
||||
<Badge status={currentConfig.api_key_configured ? 'success' : 'failed'}>
|
||||
{currentConfig.api_key_configured ? '已配置' : '未配置'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">模式</span>
|
||||
<span className="text-sm text-gray-200">多 Agent (5 专家 + 终裁)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 测试连接 */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`rounded p-3 text-xs ${
|
||||
testResult.success
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/30'
|
||||
: 'bg-red-500/10 text-red-400 border border-red-500/30'
|
||||
}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 px-4 py-2.5 text-sm text-gray-300 transition-colors hover:bg-gray-700 hover:text-white disabled:opacity-50 min-h-[44px]"
|
||||
>
|
||||
{testing ? '测试中...' : '测试 LLM 连接'}
|
||||
</button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="使用统计"
|
||||
action={
|
||||
<button
|
||||
onClick={loadStats}
|
||||
className="rounded px-2 py-1 text-xs text-blue-400 hover:bg-gray-800 min-h-[44px] min-w-[44px]"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-16 rounded bg-gray-800" />
|
||||
<div className="h-16 rounded bg-gray-800" />
|
||||
<div className="h-16 rounded bg-gray-800" />
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-3 text-center">
|
||||
<div className="text-xl font-bold text-white tabular-nums">{stats.total_predictions}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">总预测数</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-3 text-center">
|
||||
<div className="text-xl font-bold text-blue-400 tabular-nums">{stats.avg_latency_ms}ms</div>
|
||||
<div className="text-xs text-gray-500 mt-1">平均延迟</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-800/50 p-3 text-center">
|
||||
<div className="text-xl font-bold text-emerald-400 tabular-nums">
|
||||
{stats.success_rate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">成功率</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-gray-500">暂无使用统计数据</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 模型切换 */}
|
||||
<Card>
|
||||
<CardHeader title="可用模型" description="切换预测使用的 LLM 模型(通过修改 .env 文件)" />
|
||||
<CardBody>
|
||||
<div className="space-y-3">
|
||||
{AVAILABLE_MODELS.map(model => {
|
||||
const isCurrent = model.id === currentConfig.model
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border p-4 gap-3 ${
|
||||
isCurrent
|
||||
? 'border-blue-500/30 bg-blue-500/5'
|
||||
: 'border-gray-800 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-200">{model.label}</span>
|
||||
{isCurrent && <Badge status="success">当前</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{model.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge status="info">{model.provider}</Badge>
|
||||
{!isCurrent && (
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
编辑 .env 切换
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 最近预测 */}
|
||||
<Card>
|
||||
<CardHeader title="最近预测记录" />
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-12 animate-pulse rounded bg-gray-800" />
|
||||
))}
|
||||
</div>
|
||||
) : stats && stats.recent_predictions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{stats.recent_predictions.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">#{p.id}</span>
|
||||
<span className="text-sm text-gray-300">Match #{p.match_id}</span>
|
||||
<span className="text-xs font-mono text-gray-500">{p.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleString() : '—'}
|
||||
</span>
|
||||
<Badge status={p.status === 'success' ? 'success' : 'failed'}>
|
||||
{p.status === 'success' ? '成功' : '失败'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-gray-500">
|
||||
<p>暂无预测记录</p>
|
||||
<p className="mt-1 text-xs">触发预测后将在此显示记录</p>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user