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:
shangfangjian
2026-09-17 02:43:44 +08:00
parent 6680da7d61
commit 91e406f5ee
14 changed files with 1312 additions and 174 deletions
+259
View File
@@ -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>
)
}