feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲

- crypto.py: API Key 加密/解密工具
- runtime_config.py: 运行时动态配置管理
- log_buffer.py: 内存日志缓冲区
- config.py: 新增加密配置项
- http_client.py: 增强重试和错误处理
This commit is contained in:
shangfangjian
2026-09-19 11:58:03 +08:00
parent b3e2c52b49
commit 786f10aa11
57 changed files with 3178 additions and 488 deletions
+9 -2
View File
@@ -14,12 +14,15 @@ import { useEffect, useState } from 'react'
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
import type { BacktestRequest, EvalSummary, League } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import TeamSideTag from '../../components/TeamSideTag'
interface BacktestResultRow {
match_id: number
league_code?: string | null
home_team: string
away_team: string
home_team_zh?: string | null
away_team_zh?: string | null
match_date?: string | null
actual_score: string
actual_1x2?: string
@@ -279,8 +282,12 @@ export default function BacktestPage() {
<span className="w-24 flex-shrink-0 text-2xs tabular-nums text-ink-400">
{fmtDate(r.match_date)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-ink-800">
{r.home_team} vs {r.away_team}
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-ink-800">
<TeamSideTag side="home" />
<span className="truncate">{r.home_team_zh || r.home_team}</span>
<span className="flex-shrink-0 text-ink-300">vs</span>
<TeamSideTag side="away" />
<span className="truncate">{r.away_team_zh || r.away_team}</span>
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-ink-500">
<span className="font-serif font-bold text-ink-900">{r.actual_score}</span>
+25 -4
View File
@@ -47,6 +47,7 @@ export default function CollectionPage() {
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [season, setSeason] = useState('')
const [ingestStatus, setIngestStatus] = useState('') // 空 = 已完赛+未开赛
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
@@ -70,11 +71,15 @@ export default function CollectionPage() {
leagues: leagueCode ? [leagueCode] : undefined,
league: leagueCode || undefined,
season: season || undefined,
status: ingestStatus || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined,
}
const res = await triggerCollection(body)
setResult(summarizeResult(res, source))
await triggerCollection(body)
setResult({
title: '采集任务已启动',
detail: '正在后台执行(上游限速时可能需要几分钟)。完成结果与错误请到「系统日志」页查看(支持自动刷新)。',
})
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '采集触发失败')
} finally {
@@ -126,6 +131,22 @@ export default function CollectionPage() {
</select>
</div>
{/* Bzzoiro 专用: 比赛状态 */}
{source === 'bzzoiro' && (
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={ingestStatus}
onChange={e => setIngestStatus(e.target.value)}
className="field w-full"
>
<option value="">( + )</option>
<option value="finished"></option>
<option value="scheduled"></option>
</select>
</div>
)}
{/* Understat 专用: 赛季 */}
{source === 'understat' && (
<div>
@@ -199,8 +220,8 @@ export default function CollectionPage() {
</div>
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
ADMIN_API_KEY,
401
401 ,
</p>
</CardBody>
</Card>
+101 -57
View File
@@ -2,24 +2,27 @@
* Admin 后台 - 系统配置管理页面(报刊风)
*
* 功能:
* - 管理员密钥(X-API-Key):存本机浏览器,自动附带到采集/回测/结算等受保护接口
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
* - 配置修改指南
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchSystemConfig } from '../dal'
import { getAdminKey, setAdminKey } from '../api'
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
export default function ConfigPage() {
const [config, setConfig] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
// 管理员密钥
const [adminKey, setAdminKeyInput] = useState('')
const [keySaved, setKeySaved] = useState(false)
const [keyExists, setKeyExists] = useState(false)
// 修改密码表单
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 loadConfig = useCallback(async () => {
setLoading(true)
@@ -35,75 +38,115 @@ export default function ConfigPage() {
useEffect(() => {
loadConfig()
const stored = getAdminKey()
setKeyExists(stored !== '')
fetchAuthState()
.then(s => setPasswordOrigin(s.password_origin ?? null))
.catch(() => setPasswordOrigin(null))
}, [loadConfig])
function handleSaveKey(e: React.FormEvent) {
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault()
setAdminKey(adminKey.trim())
setKeyExists(adminKey.trim() !== '')
setKeySaved(true)
setAdminKeyInput('')
setTimeout(() => setKeySaved(false), 3000)
}
function handleClearKey() {
setAdminKey('')
setAdminKeyInput('')
setKeyExists(false)
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-6">
<SectionHeader
title="系统配置"
description="管理员密钥管理与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
/>
{/* 管理员密钥 */}
{/* 登录与鉴权 */}
<Card>
<CardHeader
title="管理员密钥 (X-API-Key)"
description="后端设置 ADMIN_API_KEY 后,采集 / 回测 / 结算等接口需要此密钥"
title="登录与鉴权"
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
/>
<CardBody>
<form onSubmit={handleSaveKey} className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row">
<input
type="password"
value={adminKey}
onChange={e => setAdminKeyInput(e.target.value)}
placeholder={keyExists ? '••••••••(已保存,输入新值可更换)' : '粘贴 ADMIN_API_KEY'}
autoComplete="off"
className="field flex-1"
/>
<div className="flex gap-2">
<button type="submit" disabled={!adminKey.trim()} className="btn btn-solid">
</button>
<button
type="button"
onClick={handleClearKey}
disabled={!keyExists}
className="btn btn-sm"
>
</button>
<Alert kind="ok" title="已通过密码登录" />
<p className="mt-3 text-2xs leading-relaxed text-ink-500">
<code className="font-mono">.env</code> {' '}
<code className="font-mono">ADMIN_PASSWORD</code>(),{' '}
<code className="font-mono">scrypt</code> ,
,,
<code className="font-mono">ADMIN_API_KEY</code>( X-API-Key)
</p>
{passwordOrigin && (
<p className="mt-2 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="mt-5 space-y-3 border-t border-ink-200 pt-4">
<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>
{keySaved && <Alert kind="ok" title="密钥已保存,后续请求将自动附带" />}
{keyExists && !keySaved && (
<p className="text-2xs text-ink-500">
:<Badge status="success"></Badge>
<span className="ml-2">,</span>
</p>
{pwdNotice && (
<Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />
)}
<p className="border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
.env ADMIN_API_KEY ()
401
</p>
<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>
@@ -203,7 +246,8 @@ docker compose logs -f api`}
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
['ADMIN_API_KEY', '管理后台写接口的鉴权密钥,配置后需在本页保存到浏览器'],
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
].map(([key, desc]) => (
+143 -59
View File
@@ -2,58 +2,113 @@
* Admin 后台 - 数据源管理页面(报刊风)
*
* 功能:
* - 显示当前数据源状态 (bzzoiro / understat / injuries)
* - 显示 API Key 配置状态(脱敏显示)
* - 测试连接按钮(调用采集 API 验证)
* - 数据源说明
* - 显示数据源配置状态(脱敏),标明值来源:DB 覆盖 / .env 默认 / 未配置
* - 在线修改数据源 API Key(写入 app_settings,覆盖 .env;清除则回落)
* - 测试连接按钮(后端真实请求上游一次,不触发入库)
*/
import { useEffect, useState, useCallback } from 'react'
import { fetchDataSourceStatuses, testDataSource } from '../dal'
import type { DataSourceStatus } from '../types'
import {
fetchDataSourceStatuses,
updateSetting,
clearSetting,
testDataSourceConnection,
} from '../dal'
import type { DataSourceStatus, DataSourceTestResult } from '../types'
import SettingRow from '../SettingRow'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
function formatTime(iso: string | null): string {
if (!iso) return '暂无记录'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
export default function DataSourcesPage() {
const [sources, setSources] = useState<DataSourceStatus[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [testingSource, setTestingSource] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, { success: boolean; message: string }>>({})
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
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)
const loadSources = useCallback(async () => {
setLoading(true)
setLoadError('')
try {
const data = await fetchDataSourceStatuses()
setSources(data)
} catch {
setSources(await fetchDataSourceStatuses())
} catch (err) {
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
setSources([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadSources() }, [loadSources])
useEffect(() => {
loadSources()
}, [loadSources])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: { success: false, message: '测试中...' } }))
try {
await testDataSource(sourceName as 'bzzoiro' | 'understat' | 'injuries')
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 } }))
const result = await testDataSourceConnection(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: result }))
} catch (err) {
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
} finally {
setTestingSource(null)
}
}
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
return (
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="数据采集源的配置状态与连通性测试。"
description="数据采集源的 API 配置与连通性测试。修改保存到数据库并立即生效,无需重启;「回落 .env」删除覆盖值。"
/>
{loadError && (
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
)}
{/* 数据源卡片 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -73,39 +128,73 @@ export default function DataSourcesPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sources.map(source => {
const result = testResults[source.name]
const cardKeys = source.settings.map(s => s.key)
return (
<Card key={source.name}>
<CardBody className="space-y-4">
{/* 头部 */}
<div className="flex items-center justify-between border-b border-ink-200 pb-3">
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
<Badge status={source.keyConfigured ? 'success' : 'error'}>
{source.keyConfigured ? '已配置' : '配置'}
<Badge status={source.key_configured ? 'success' : 'error'}>
{source.key_configured ? '已就绪' : '配置'}
</Badge>
</div>
{/* API Key 状态 */}
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400">API Key</span>
<span className="font-mono text-ink-600">{source.maskedKey}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{source.lastIngestion || '暂无记录'}</span>
</div>
</div>
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
{/* 测试结果 */}
{result && (
{/* 配置项 */}
{source.settings.length > 0 ? (
<div>
{source.settings.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)}
/>
))}
</div>
) : (
<p className="text-2xs text-ink-400"> API Key</p>
)}
{/* 行级操作提示 */}
{rowNotice && cardKeys.includes(rowNotice.key) && (
<Alert
kind={result.success ? 'ok' : 'error'}
title={result.success ? '连接成功' : '连接失败'}
message={result.success ? undefined : result.message}
kind={rowNotice.ok ? 'ok' : 'error'}
title={rowNotice.text}
/>
)}
{/* 操作按钮 */}
{/* 最近采集 */}
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{formatTime(source.last_ingestion)}</span>
</div>
{/* 测试结果(进行中不渲染,避免占位被误读为失败) */}
{result && testingSource !== source.name && (
<Alert
kind={result.ok ? 'ok' : 'error'}
title={
result.ok
? `连接成功(${result.latency_ms}ms)`
: result.status
? `HTTP ${result.status}`
: '连接失败'
}
message={result.ok ? undefined : result.detail}
/>
)}
{/* 测试按钮 */}
<button
onClick={() => handleTest(source.name)}
disabled={testingSource === source.name}
@@ -120,29 +209,24 @@ export default function DataSourcesPage() {
</div>
)}
{/* 数据源说明 */}
{/* 配置说明 */}
<Card>
<CardHeader title="数据源说明" />
<CardHeader title="配置说明" />
<CardBody>
<div className="grid gap-x-6 gap-y-3 sm:grid-cols-3">
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Bzzoiro</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
, API Key
</p>
</div>
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Understat</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
xG(), API Key,
</p>
</div>
<div className="border-t border-ink-200 pt-3">
<Badge status="info">Injuries</Badge>
<p className="mt-2 text-xs leading-relaxed text-ink-600">
, API Key
</p>
</div>
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
<p className="border-l-2 border-ink-300 pl-3">
<code className="font-mono">app_settings</code> <b></b>,
<code className="font-mono">.env</code> ; .env
,Key
</p>
<p className="border-l-2 border-ink-300 pl-3">
4 ( 8 ),
</p>
<p className="border-l-2 border-ink-300 pl-3">
会真实请求上游接口一次:连通且密钥有效 ;
HTTP 401/403 ;
</p>
</div>
</CardBody>
</Card>
+110 -67
View File
@@ -9,17 +9,14 @@
*/
import { useEffect, useState, useCallback } from 'react'
import { testLLMConnection, fetchLLMUsageStats } from '../dal'
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
import type { LLMUsageStats } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
import type { DataSourceSetting } from '../types'
// 可用模型列表
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: '高性价比,中文优化' },
]
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
export default function LLMConfigPage() {
const [stats, setStats] = useState<LLMUsageStats | null>(null)
@@ -27,14 +24,12 @@ export default function LLMConfigPage() {
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// 当前配置(后端暂无配置端点,取 .env 约定值展示)
const currentConfig = {
provider: 'openai',
model: 'gpt-4o',
base_url: 'https://api.openai.com/v1',
api_key_configured: true,
api_key_masked: 'sk-****...****abcd',
}
// LLM 连接配置(运行时配置,DB 覆盖 .env)
const [llmSettings, setLlmSettings] = 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)
const loadStats = useCallback(async () => {
setLoading(true)
@@ -48,7 +43,58 @@ export default function LLMConfigPage() {
}
}, [])
useEffect(() => { loadStats() }, [loadStats])
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
} catch {
setLlmSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
useEffect(() => {
loadStats()
loadSettings()
}, [loadStats, loadSettings])
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(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)
}
}
async function handleTest() {
setTesting(true)
@@ -72,29 +118,53 @@ export default function LLMConfigPage() {
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 当前配置 */}
{/* LLM 连接配置 */}
<Card>
<CardHeader title="当前配置" />
<CardHeader
title="连接配置"
description="保存到数据库并立即生效,优先于服务器 .env"
action={
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
{settingsLoading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody>
<div className="space-y-0">
{[
{ label: '提供商', value: currentConfig.provider, mono: false },
{ label: '模型', value: currentConfig.model, mono: true },
{ label: 'API 地址', value: currentConfig.base_url, mono: true },
{ label: 'API Key', value: currentConfig.api_key_masked, mono: true },
{ label: '模式', value: '多专家 (5 路 + 终裁)', mono: false },
].map(row => (
<div
key={row.label}
className="flex items-center justify-between gap-4 border-b border-ink-200 py-2.5 last:border-b-0"
>
<span className="text-xs text-ink-400">{row.label}</span>
<span className={`text-xs text-ink-800 ${row.mono ? 'break-all font-mono' : ''}`}>
{row.value}
</span>
</div>
))}
</div>
{settingsLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : (
<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}
/>
))}
</div>
)}
{rowNotice && (
<div className="mt-3">
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
</div>
)}
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
模式: 多专家 (5 + ) OpenAI ( DeepSeek
) LLM
</p>
{/* 测试连接 */}
{testResult && (
@@ -161,35 +231,8 @@ export default function LLMConfigPage() {
</Card>
</div>
{/* 可用模型 */}
<Card>
<CardHeader title="可用模型" description="在 .env 中修改 LLM_MODEL 后重启服务生效" />
<CardBody className="px-0 sm:px-0">
{AVAILABLE_MODELS.map(model => {
const isCurrent = model.id === currentConfig.model
return (
<div
key={model.id}
className={`flex flex-col gap-2 border-b border-ink-200 px-4 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5 ${
isCurrent ? 'bg-press-wash/50' : ''
}`}
>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink-900">{model.label}</span>
{isCurrent && <Badge status="success"></Badge>}
</div>
<p className="mt-0.5 text-2xs text-ink-500">{model.description}</p>
</div>
<div className="flex items-center gap-3">
<span className="font-mono text-2xs text-ink-400">{model.provider}</span>
{!isCurrent && <span className="text-2xs text-ink-400"> .env </span>}
</div>
</div>
)
})}
</CardBody>
</Card>
{/* 专家与终裁独立配置 */}
<AgentLLMCard />
{/* 最近预测 */}
<Card>
+153
View File
@@ -0,0 +1,153 @@
/**
* Admin 后台 - 系统日志页面(报刊风)
*
* 查看应用运行日志(内存缓冲,最新在前):
* - 级别筛选 + 关键字搜索
* - 自动刷新(10s)可开关
* - 缓冲上限 2000 条,进程重启后清零
*/
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchLogs } from '../dal'
import type { LogEntry } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'error'; text: string }> = {
DEBUG: { status: 'info', text: 'DEBUG' },
INFO: { status: 'info', text: 'INFO' },
WARNING: { status: 'warning', text: 'WARN' },
ERROR: { status: 'error', text: 'ERROR' },
CRITICAL: { status: 'error', text: 'FATAL' },
}
function fmtTs(ts: number): string {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
export default function LogsPage() {
const [entries, setEntries] = useState<LogEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [level, setLevel] = useState<string>('')
const [keyword, setKeyword] = useState('')
const [autoRefresh, setAutoRefresh] = useState(true)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const load = useCallback(async () => {
try {
const d = await fetchLogs({ level: level || undefined, keyword: keyword || undefined, limit: 300 })
setEntries(d.entries)
setError('')
} catch (err) {
setError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
} finally {
setLoading(false)
}
}, [level, keyword])
// 筛选条件变化 → 立即拉取
useEffect(() => {
load()
}, [load])
// 自动刷新
useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current)
if (autoRefresh) {
timerRef.current = setInterval(load, 10_000)
}
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [autoRefresh, load])
return (
<div className="space-y-6">
<SectionHeader
title="系统日志"
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
/>
{error && <Alert kind="error" title="无法加载日志" message={error} />}
<Card>
<CardHeader
title="日志查看"
description={entries.length > 0 ? `显示最新 ${entries.length}` : undefined}
action={
<div className="flex items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
<input
type="checkbox"
checked={autoRefresh}
onChange={e => setAutoRefresh(e.target.checked)}
className="accent-current"
/>
10s
</label>
<button onClick={load} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
</div>
}
/>
<CardBody className="px-0 sm:px-0">
{/* 筛选栏 */}
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div className="flex flex-wrap gap-1.5">
{LEVELS.map(lv => (
<button
key={lv || 'all'}
onClick={() => setLevel(lv)}
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
>
{lv || '全部'}
</button>
))}
</div>
<input
value={keyword}
onChange={e => setKeyword(e.target.value)}
placeholder="搜索关键字(消息 / logger)…"
className="field w-full sm:w-64"
/>
</div>
{/* 日志列表 */}
{loading && entries.length === 0 ? (
<div className="space-y-2 px-4 py-3 sm:px-5">
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : entries.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400"></p>
) : (
<div>
{entries.map((e, i) => {
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
return (
<div
key={`${e.ts}-${i}`}
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
>
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
<span className="w-14 flex-shrink-0">
<Badge status={badge.status}>{badge.text}</Badge>
</span>
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
{e.logger}
</span>
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
{e.message}
</span>
</div>
)
})}
</div>
)}
</CardBody>
</Card>
</div>
)
}
+1 -1
View File
@@ -66,7 +66,7 @@ export default function MonitoringPage() {
<Alert
kind="error"
title="无法连接到后端"
message={`${error}\n请确认服务是否正常运行,以及管理员密钥是否需要配置`}
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期`}
/>
)}
+8 -7
View File
@@ -13,13 +13,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal'
import type { Match, Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { teamSidePrefix } from '../../components/TeamSideTag'
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋',
form: '近期状态',
stats: '攻防数据',
home_away: '主客因素',
injuries: '阵容完整性',
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
injuries: '阵容完整性分析专家',
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
@@ -64,7 +65,7 @@ export default function PredictionsPage() {
for (const m of matches) {
const home = m.home_team_zh || m.home_team
const away = m.away_team_zh || m.away_team
map.set(m.id, `${home} vs ${away}`)
map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
}
return map
}, [matches])
@@ -140,7 +141,7 @@ export default function PredictionsPage() {
<option value=""></option>
{matches.map(m => (
<option key={m.id} value={m.id}>
{(m.home_team_zh || m.home_team)} vs {(m.away_team_zh || m.away_team)}
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)})
</option>
))}