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
+83 -29
View File
@@ -1,11 +1,14 @@
/**
* Admin 后台 - 回测管理页面
*
* 响应式布局: 移动端单列,桌面端双列
* 触摸友好: 按钮最小 44px 高度
*/
import { useState } from 'react'
import { triggerBacktest, fetchEvalSummary } from '../dal'
import type { BacktestRequest, EvalSummary } from '../types'
import { Card, CardBody, CardHeader, Badge } from '../components'
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
export default function BacktestPage() {
const [leagueId, setLeagueId] = useState('')
@@ -47,72 +50,104 @@ export default function BacktestPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="mt-1 text-sm text-gray-400"></p>
</div>
<SectionHeader
title="回测管理"
description="在历史数据上运行预测并评估准确率"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 回测配置 */}
<Card>
<CardHeader title="回测配置" />
<CardBody>
<form onSubmit={handleBacktest} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"> ID ()</label>
<input type="number" value={leagueId} onChange={e => setLeagueId(e.target.value)}
placeholder="留空=全部" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="number"
value={leagueId}
onChange={e => setLeagueId(e.target.value)}
placeholder="留空=全部"
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="number" value={limit} onChange={e => setLimit(parseInt(e.target.value) || 20)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="number"
value={limit}
onChange={e => setLimit(parseInt(e.target.value) || 20)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={mode} onChange={e => setMode(e.target.value as any)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
>
<option value="single"> ()</option>
<option value="multi"> Agent ()</option>
</select>
</div>
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
{error && (
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
{error}
</div>
)}
<button type="submit" disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
>
{loading ? '回测中...' : '开始回测'}
</button>
</form>
</CardBody>
</Card>
{/* 结果区域 */}
<div className="space-y-6">
{result && (
<Card>
<CardHeader title="回测结果" />
<CardBody>
<div className="grid grid-cols-2 gap-4">
<div className="rounded bg-gray-800 p-3 text-center">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg bg-gray-800 p-4 text-center">
<div className="text-2xl font-bold text-white">{result.scored}/{result.total}</div>
<div className="text-xs text-gray-400"></div>
<div className="text-xs text-gray-400 mt-1"></div>
</div>
<div className="rounded bg-gray-800 p-3 text-center">
<div className="rounded-lg bg-gray-800 p-4 text-center">
<div className="text-2xl font-bold text-blue-400">
{result.accuracy_1x2?.toFixed(1) ?? '—'}%
</div>
<div className="text-xs text-gray-400">1X2 </div>
<div className="text-xs text-gray-400 mt-1">1X2 </div>
</div>
</div>
</CardBody>
@@ -120,21 +155,40 @@ export default function BacktestPage() {
)}
<Card>
<CardHeader title="模型评估" action={<button onClick={loadEval} className="text-xs text-blue-400 hover:underline"></button>} />
<CardHeader
title="模型评估"
action={
<button
onClick={loadEval}
className="rounded px-2 py-1 text-xs text-blue-400 hover:bg-gray-800 min-h-[44px] min-w-[44px]"
>
</button>
}
/>
<CardBody>
{!evalSummary ? (
<p className="text-gray-400">"刷新"</p>
<div className="text-center py-8 text-sm text-gray-500">
<p>"刷新"</p>
</div>
) : evalSummary.summary?.length > 0 ? (
<div className="space-y-2">
{evalSummary.summary.map((s: any, i: number) => (
<div key={i} className="flex items-center justify-between rounded border border-gray-700 p-2">
<div
key={i}
className="flex flex-col sm:flex-row sm:items-center justify-between rounded-lg border border-gray-800 p-3 gap-2"
>
<span className="text-sm text-gray-300">{s.provider}/{s.model}</span>
<Badge status="info">{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})</Badge>
<Badge status="info">
{s.accuracy?.toFixed(1)}% ({s.correct}/{s.total})
</Badge>
</div>
))}
</div>
) : (
<p className="text-gray-400"></p>
<div className="text-center py-8 text-sm text-gray-500">
<p></p>
</div>
)}
</CardBody>
</Card>
+80 -26
View File
@@ -1,11 +1,14 @@
/**
* Admin 后台 - 数据采集页面
*
* 响应式布局: 移动端单列,桌面端双列
* 触摸友好: 按钮最小 44px 高度
*/
import { useEffect, useState, useCallback } from 'react'
import { triggerCollection, fetchLeagues } from '../dal'
import type { CollectionRequest, League } from '../types'
import { Card, CardBody, CardHeader, Badge } from '../components'
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
const SOURCES = [
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
@@ -57,80 +60,131 @@ export default function CollectionPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="mt-1 text-sm text-gray-400">,</p>
</div>
<SectionHeader
title="数据采集"
description="触发数据源采集,支持联赛筛选和日期范围"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 采集表单 */}
<Card>
<CardHeader title="新建采集任务" />
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 数据源选择 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={source} onChange={e => setSource(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
{SOURCES.map(s => <option key={s.value} value={s.value}>{s.label} {s.desc}</option>)}
<select
value={source}
onChange={e => setSource(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
>
{SOURCES.map(s => (
<option key={s.value} value={s.value}>
{s.label} {s.desc}
</option>
))}
</select>
</div>
{/* 联赛选择 */}
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={leagueCode} onChange={e => setLeagueCode(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<select
value={leagueCode}
onChange={e => setLeagueCode(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
>
<option value=""></option>
{leagues.map(l => <option key={l.code} value={l.code}>{l.name_zh || l.name}</option>)}
{leagues.map(l => (
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
))}
</select>
</div>
{/* Understat 专用: 赛季 */}
{source === 'understat' && (
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400">()</label>
<input type="number" value={season} onChange={e => setSeason(e.target.value)}
placeholder="2025" className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="number"
value={season}
onChange={e => setSeason(e.target.value)}
placeholder="2025"
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
)}
{/* 日期范围 */}
{source !== 'injuries' && (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="date"
value={dateFrom}
onChange={e => setDateFrom(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white" />
<input
type="date"
value={dateTo}
onChange={e => setDateTo(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
/>
</div>
</>
</div>
)}
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
{successMsg && <div className="rounded bg-green-500/10 p-3 text-sm text-green-400">{successMsg}</div>}
{/* 消息提示 */}
{error && (
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
{error}
</div>
)}
{successMsg && (
<div className="rounded-md bg-emerald-500/10 p-3 text-sm text-emerald-400 border border-emerald-500/30">
{successMsg}
</div>
)}
<button type="submit" disabled={loading}
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
{/* 提交按钮 */}
<button
type="submit"
disabled={loading}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
>
{loading ? '采集中...' : '触发采集'}
</button>
</form>
</CardBody>
</Card>
{/* 数据源说明 */}
<Card>
<CardHeader title="数据源说明" />
<CardBody>
<div className="space-y-3">
{SOURCES.map(s => (
<div key={s.value} className="rounded border border-gray-700 p-3">
<div className="flex items-center gap-2">
<div key={s.value} className="rounded-lg border border-gray-800 p-4">
<div className="flex items-center gap-2 mb-2">
<Badge status="info">{s.label}</Badge>
<span className="text-sm text-gray-300">{s.desc}</span>
</div>
<p className="text-sm text-gray-400">{s.desc}</p>
</div>
))}
</div>
{/* 移动端提示 */}
<div className="mt-4 rounded-lg border border-blue-500/30 bg-blue-500/5 p-3">
<p className="text-xs text-blue-300">
💡 ,,
</p>
</div>
</CardBody>
</Card>
</div>
+173 -44
View File
@@ -1,60 +1,189 @@
/**
* Admin 后台 - 配置管理
* Admin 后台 - 系统配置管理页面
*
* 提示: API Key 配置通过 .env 文件管理,不在前端明文存储。
* 功能:
* - 显示当前 .env 配置(脱敏)
* - 提供配置修改指南
* - 快速导航到数据源和 LLM 配置
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchSystemConfig } from '../dal'
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
export default function ConfigPage() {
const [config, setConfig] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const loadConfig = useCallback(async () => {
setLoading(true)
try {
const data = await fetchSystemConfig()
setConfig(data)
} catch {
setConfig([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadConfig() }, [loadConfig])
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="mt-1 text-sm text-gray-400">API Key </p>
</div>
<SectionHeader
title="系统配置"
description="查看当前系统配置参数(通过 .env 文件管理)"
/>
<div className="rounded border border-yellow-500/30 bg-yellow-500/10 p-4">
<p className="text-sm text-yellow-300">
API Key <code className="rounded bg-gray-800 px-1">.env</code> ,
</p>
</div>
<div className="rounded border border-gray-700 bg-gray-800 p-4">
<h3 className="mb-3 text-sm font-medium text-white"></h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between border-b border-gray-700 py-2">
<span className="text-gray-400">LLM_API_KEY</span>
<span className="text-gray-300"> .env </span>
</div>
<div className="flex justify-between border-b border-gray-700 py-2">
<span className="text-gray-400">LLM_BASE_URL</span>
<span className="text-gray-300"> .env </span>
</div>
<div className="flex justify-between border-b border-gray-700 py-2">
<span className="text-gray-400">BZZOIRO_KEY</span>
<span className="text-gray-300"> .env </span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">API_FOOTBALL_KEY</span>
<span className="text-gray-300"> .env </span>
{/* 配置警告 */}
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4">
<div className="flex items-start gap-3">
<span className="text-yellow-400 text-lg"></span>
<div>
<p className="text-sm font-medium text-yellow-300"></p>
<p className="mt-1 text-xs text-yellow-300/80">
(API Key) <code className="rounded bg-gray-800/50 px-1">.env</code>
, SSH
</p>
</div>
</div>
</div>
<div className="rounded border border-gray-700 bg-gray-800 p-4">
<h3 className="mb-3 text-sm font-medium text-white"></h3>
<p className="text-sm text-gray-400">
SSH NAS,:
</p>
<pre className="mt-2 rounded bg-gray-900 p-3 text-xs text-green-400">
{`cd /vol2/1000/Docker/Profeto
# 编辑 .env 文件
LLM_API_KEY=sk-你的真实密钥
BZZOIRO_KEY=你的密钥
# 重启后端
docker compose restart api`}
</pre>
{/* 快速导航 */}
<div className="grid gap-4 sm:grid-cols-2">
<a
href="/admin/data-sources"
className="flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-900 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-400 text-lg">
</div>
<div>
<div className="text-sm font-medium text-gray-200"></div>
<div className="text-xs text-gray-500"> API Key</div>
</div>
</a>
<a
href="/admin/llm-config"
className="flex items-center gap-3 rounded-lg border border-gray-800 bg-gray-900 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-400 text-lg">
</div>
<div>
<div className="text-sm font-medium text-gray-200">LLM </div>
<div className="text-xs text-gray-500"></div>
</div>
</a>
</div>
{/* 配置列表 */}
<Card>
<CardHeader
title="当前配置"
action={
<button
onClick={loadConfig}
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="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-10 animate-pulse rounded bg-gray-800" />
))}
</div>
) : config.length > 0 ? (
<div className="space-y-1">
{/* 桌面端表头 */}
<div className="hidden sm:grid sm:grid-cols-3 gap-4 border-b border-gray-800 px-3 py-2 text-xs text-gray-500">
<span></span>
<span></span>
<span></span>
</div>
{/* 配置行 */}
{config.map(item => (
<div
key={item.key}
className="flex flex-col sm:grid sm:grid-cols-3 gap-2 sm:gap-4 border-b border-gray-800/50 px-3 py-3 hover:bg-gray-800/30 rounded-lg sm:rounded-none"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-300">{item.key}</span>
{item.is_sensitive && (
<Badge status="warning"></Badge>
)}
</div>
<div className="font-mono text-xs text-gray-400 break-all">
{item.value_masked}
</div>
<div className="text-xs text-gray-500">
{item.description}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-sm text-gray-500"></div>
)}
</CardBody>
</Card>
{/* 修改指南 */}
<Card>
<CardHeader title="修改配置指南" />
<CardBody className="space-y-4">
<div className="rounded-lg border border-gray-800 p-4">
<h4 className="text-sm font-medium text-gray-200 mb-2"> SSH .env</h4>
<pre className="overflow-x-auto rounded bg-gray-950 p-3 text-xs text-green-400 leading-relaxed">
{`# 连接到 NAS
ssh user@your-nas-ip
# 进入项目目录
cd /vol2/1000/Docker/Profeto
# 编辑 .env 文件
nano .env
# 修改后重启后端服务
docker compose restart api
# 查看日志确认生效
docker compose logs -f api`}
</pre>
</div>
<div className="rounded-lg border border-gray-800 p-4">
<h4 className="text-sm font-medium text-gray-200 mb-2"></h4>
<div className="space-y-2 text-xs">
<div className="flex items-start gap-2">
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">LLM_API_KEY</code>
<span className="text-gray-400">LLM API ,</span>
</div>
<div className="flex items-start gap-2">
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">LLM_MODEL</code>
<span className="text-gray-400">使, gpt-4oclaude-3-5-sonnet</span>
</div>
<div className="flex items-start gap-2">
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">LLM_BASE_URL</code>
<span className="text-gray-400">API , OpenAI </span>
</div>
<div className="flex items-start gap-2">
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">BZZOIRO_KEY</code>
<span className="text-gray-400">Bzzoiro API </span>
</div>
<div className="flex items-start gap-2">
<code className="flex-shrink-0 rounded bg-gray-800 px-1.5 py-0.5 text-blue-400">DATABASE_URL</code>
<span className="text-gray-400">PostgreSQL </span>
</div>
</div>
</div>
</CardBody>
</Card>
</div>
)
}
+70 -7
View File
@@ -1,5 +1,7 @@
/**
* Admin 后台 - 仪表盘
*
* 响应式布局: 移动端 1 列 → 平板 2 列 → 桌面 4 列
*/
import { useEffect, useState } from 'react'
@@ -39,7 +41,7 @@ export default function Dashboard() {
<p className="mt-1 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-3 rounded bg-red-500/20 px-4 py-1 text-sm hover:bg-red-500/30"
className="mt-3 rounded-md bg-red-500/20 px-4 py-2 text-sm hover:bg-red-500/30 min-h-[44px]"
>
</button>
@@ -49,18 +51,40 @@ export default function Dashboard() {
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<StatCard label="健康状态" value={loading ? '—' : (health?.status === 'healthy' ? '✅ 正常' : '⚠️ 异常')} icon="●" />
<StatCard label="联赛数" value={loading ? '—' : data?.leagues.length ?? 0} icon="◫" />
<StatCard label="比赛数" value={loading ? '—' : data?.total_matches ?? 0} icon="◆" />
<StatCard label="预测数" value={loading ? '—' : data?.total_predictions ?? 0} icon="◇" />
{/* 统计卡片: 移动端 1 列 → 平板 2 列 → 桌面 4 列 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="健康状态"
value={loading ? '—' : (health?.status === 'healthy' ? '✅ 正常' : '⚠️ 异常')}
icon="●"
/>
<StatCard
label="联赛数"
value={loading ? '—' : data?.leagues.length ?? 0}
icon="◫"
/>
<StatCard
label="比赛数"
value={loading ? '—' : data?.total_matches ?? 0}
icon="◆"
/>
<StatCard
label="预测数"
value={loading ? '—' : data?.total_predictions ?? 0}
icon="◇"
/>
</div>
{/* 联赛列表 */}
<Card>
<CardHeader title="最近联赛" />
<CardBody>
{loading ? (
<p className="text-gray-400">...</p>
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-6 w-20 animate-pulse rounded bg-gray-800" />
))}
</div>
) : data && data.leagues.length > 0 ? (
<div className="flex flex-wrap gap-2">
{data.leagues.map(l => (
@@ -74,6 +98,45 @@ export default function Dashboard() {
)}
</CardBody>
</Card>
{/* 快捷操作 */}
<Card>
<CardHeader title="快捷操作" />
<CardBody>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<a
href="/admin/collection"
className="flex items-center gap-3 rounded-lg border border-gray-800 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
>
<span className="text-xl"></span>
<div>
<div className="text-sm font-medium text-gray-200"></div>
<div className="text-xs text-gray-500"></div>
</div>
</a>
<a
href="/admin/predictions"
className="flex items-center gap-3 rounded-lg border border-gray-800 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
>
<span className="text-xl"></span>
<div>
<div className="text-sm font-medium text-gray-200"></div>
<div className="text-xs text-gray-500">使 LLM </div>
</div>
</a>
<a
href="/admin/backtest"
className="flex items-center gap-3 rounded-lg border border-gray-800 p-4 transition-colors hover:border-gray-700 hover:bg-gray-800/50"
>
<span className="text-xl"></span>
<div>
<div className="text-sm font-medium text-gray-200"></div>
<div className="text-xs text-gray-500"></div>
</div>
</a>
</div>
</CardBody>
</Card>
</div>
)
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Admin 后台 - 数据源管理页面
*
* 功能:
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
* - 显示 API Key 配置状态(脱敏显示)
* - 测试连接按钮(调用采集 API 测试)
* - 采集历史记录
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchDataSourceStatuses, testDataSource } from '../dal'
import type { DataSourceStatus } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
export default function DataSourcesPage() {
const [sources, setSources] = useState<DataSourceStatus[]>([])
const [loading, setLoading] = useState(true)
const [testingSource, setTestingSource] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
const loadSources = useCallback(async () => {
setLoading(true)
try {
const data = await fetchDataSourceStatuses()
setSources(data)
} catch {
setSources([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadSources() }, [loadSources])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
try {
await testDataSource(sourceName as any)
setTestResults(prev => ({ ...prev, [sourceName]: { success: true, message: '连接成功' } }))
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: msg } }))
} finally {
setTestingSource(null)
}
}
return (
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="管理数据采集源配置与连接状态"
/>
{/* 数据源卡片 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Card key={i}>
<CardBody>
<div className="animate-pulse space-y-3">
<div className="h-4 w-24 rounded bg-gray-800" />
<div className="h-3 w-32 rounded bg-gray-800" />
<div className="h-8 w-full rounded bg-gray-800" />
</div>
</CardBody>
</Card>
))}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sources.map(source => {
const result = testResults[source.name]
return (
<Card key={source.name}>
<CardBody className="space-y-4">
{/* 头部 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-white">{source.label}</h3>
<Badge status={source.keyConfigured ? 'success' : 'failed'}>
{source.keyConfigured ? '已配置' : '未配置'}
</Badge>
</div>
</div>
{/* API Key 状态 */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">API Key</span>
<span className="font-mono text-gray-400">{source.maskedKey}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500"></span>
<span className="text-gray-400">{source.lastIngestion || '暂无记录'}</span>
</div>
</div>
{/* 测试结果 */}
{result && (
<div
className={`rounded p-2 text-xs ${
result.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'
}`}
>
{result.message}
</div>
)}
{/* 操作按钮 */}
<button
onClick={() => handleTest(source.name)}
disabled={testingSource === source.name}
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]"
>
{testingSource === source.name ? '测试中...' : '测试连接'}
</button>
</CardBody>
</Card>
)
})}
</div>
)}
{/* 数据源说明 */}
<Card>
<CardHeader title="数据源说明" />
<CardBody>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border border-gray-800 p-4">
<div className="flex items-center gap-2 mb-2">
<Badge status="info">Bzzoiro</Badge>
</div>
<p className="text-xs text-gray-400">
, API Key
</p>
</div>
<div className="rounded-lg border border-gray-800 p-4">
<div className="flex items-center gap-2 mb-2">
<Badge status="info">Understat</Badge>
</div>
<p className="text-xs text-gray-400">
xG () , API Key,
</p>
</div>
<div className="rounded-lg border border-gray-800 p-4">
<div className="flex items-center gap-2 mb-2">
<Badge status="info">Injuries</Badge>
</div>
<p className="text-xs text-gray-400">
, API Key
</p>
</div>
</div>
</CardBody>
</Card>
{/* 采集历史 */}
<Card>
<CardHeader title="采集历史" />
<CardBody>
<div className="text-center py-8 text-sm text-gray-500">
<p></p>
<p className="mt-1 text-xs">,</p>
</div>
</CardBody>
</Card>
</div>
)
}
+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>
)
}
+76 -16
View File
@@ -1,9 +1,13 @@
/**
* Admin 后台 - 监控面板
*
* 响应式布局: 移动端单列,桌面端三列
* 触摸友好: 卡片可点击查看详情
*/
import { useEffect, useState } from 'react'
import { fetchHealth } from '../dal'
import { SectionHeader } from '../components'
export default function MonitoringPage() {
const [health, setHealth] = useState<any>(null)
@@ -15,30 +19,86 @@ export default function MonitoringPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="mt-1 text-sm text-gray-400"></p>
</div>
<SectionHeader
title="系统监控"
description="查看服务健康状态"
/>
{loading ? (
<p className="text-gray-400">...</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-gray-800" />
))}
</div>
) : health ? (
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded border border-gray-700 bg-gray-800 p-4">
<div className="text-sm text-gray-400"></div>
<div className="mt-1 text-xl font-bold text-green-400"> {health.status}</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* 服务状态 */}
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex items-center gap-2">
<span className="inline-block h-3 w-3 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-lg font-bold text-emerald-400">{health.status}</span>
</div>
</div>
<div className="rounded border border-gray-700 bg-gray-800 p-4">
<div className="text-sm text-gray-400"></div>
<div className="mt-1 text-xl font-bold text-white">{health.service || 'profeto'}</div>
{/* 服务名称 */}
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="text-lg font-bold text-white">{health.service || 'profeto'}</div>
</div>
<div className="rounded border border-gray-700 bg-gray-800 p-4">
<div className="text-sm text-gray-400"></div>
<div className="mt-1 text-xl font-bold text-blue-400">11 </div>
{/* 数据库 */}
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="text-lg font-bold text-blue-400">11 </div>
</div>
{/* 版本 */}
{health.version && (
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="text-lg font-bold text-gray-200">{health.version}</div>
</div>
)}
{/* 运行时间 */}
{health.uptime_seconds && (
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="text-lg font-bold text-gray-200">
{Math.floor(health.uptime_seconds / 3600)}h {Math.floor((health.uptime_seconds % 3600) / 60)}m
</div>
</div>
)}
{/* 检查项 */}
{health.checks && Object.keys(health.checks).length > 0 && (
<div className="rounded-lg border border-gray-800 bg-gray-900 p-5">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="space-y-1">
{Object.entries(health.checks).map(([key, val]) => (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-gray-400 text-xs">{key}</span>
<span className={`text-xs font-medium ${val === 'pass' ? 'text-emerald-400' : 'text-red-400'}`}>
{String(val)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<p className="text-gray-400"></p>
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-6 text-center">
<p className="text-red-400 text-lg font-medium"></p>
<p className="text-red-400/70 text-sm mt-1"></p>
<button
onClick={() => window.location.reload()}
className="mt-4 rounded-md bg-red-500/20 px-4 py-2 text-sm text-red-300 hover:bg-red-500/30 min-h-[44px]"
>
</button>
</div>
)}
</div>
)
+43 -15
View File
@@ -1,11 +1,14 @@
/**
* Admin 后台 - 预测管理页面
*
* 响应式布局: 移动端单列,桌面端双列
* 触摸友好: 按钮最小 44px 高度
*/
import { useEffect, useState } from 'react'
import { triggerPrediction, fetchPredictions, fetchMatches } from '../dal'
import type { Match, Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge } from '../components'
import { Card, CardBody, CardHeader, Badge, SectionHeader } from '../components'
export default function PredictionsPage() {
const [matches, setMatches] = useState<Match[]>([])
@@ -40,20 +43,24 @@ export default function PredictionsPage() {
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="mt-1 text-sm text-gray-400"> LLM </p>
</div>
<SectionHeader
title="预测管理"
description="触发 LLM 足球预测"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 新建预测 */}
<Card>
<CardHeader title="新建预测" />
<CardBody>
<form onSubmit={handlePredict} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={matchId} onChange={e => setMatchId(e.target.value)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<select
value={matchId}
onChange={e => setMatchId(e.target.value)}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
>
<option value=""></option>
{matches.map(m => (
<option key={m.id} value={m.id}>
@@ -65,33 +72,54 @@ export default function PredictionsPage() {
<div>
<label className="mb-1.5 block text-xs font-medium text-gray-400"></label>
<select value={mode} onChange={e => setMode(e.target.value as any)}
className="w-full rounded border border-gray-700 bg-gray-800 px-3 py-2 text-white">
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="w-full rounded-md border border-gray-700 bg-gray-800 px-3 py-2.5 text-white min-h-[44px]"
>
<option value="multi"> Agent (5 + )</option>
<option value="single"></option>
</select>
</div>
{error && <div className="rounded bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
{successMsg && <div className="rounded bg-green-500/10 p-3 text-sm text-green-400">{successMsg}</div>}
{error && (
<div className="rounded-md bg-red-500/10 p-3 text-sm text-red-400 border border-red-500/30">
{error}
</div>
)}
{successMsg && (
<div className="rounded-md bg-emerald-500/10 p-3 text-sm text-emerald-400 border border-emerald-500/30">
{successMsg}
</div>
)}
<button type="submit" disabled={loading || !matchId}
className="w-full rounded bg-blue-600 py-2 text-white hover:bg-blue-700 disabled:opacity-50">
<button
type="submit"
disabled={loading || !matchId}
className="w-full rounded-md bg-blue-600 px-4 py-2.5 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors min-h-[44px]"
>
{loading ? '预测中...' : '触发预测'}
</button>
</form>
</CardBody>
</Card>
{/* 最近预测 */}
<Card>
<CardHeader title="最近预测" />
<CardBody>
{predictions.length === 0 ? (
<p className="text-gray-400"></p>
<div className="text-center py-8 text-sm text-gray-500">
<p></p>
<p className="mt-1 text-xs"></p>
</div>
) : (
<div className="space-y-2">
{predictions.slice(0, 10).map(p => (
<div key={p.id} className="flex items-center justify-between rounded border border-gray-700 p-2">
<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"
>
<span className="text-sm text-gray-300">
Match #{p.match_id} · {p.model}
</span>