Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a1eed283 | ||
|
|
9c77efaa4b | ||
|
|
b9beddacf7 | ||
|
|
432bfee8ad | ||
|
|
f1c586111f | ||
|
|
db3c64d49b | ||
|
|
d013407aa1 | ||
|
|
0ab838258c | ||
|
|
9cedb874f7 | ||
|
|
1cdc6411f5 |
+5
-58
@@ -11,48 +11,17 @@
|
|||||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import Masthead from './components/Masthead'
|
||||||
import Matches from './pages/Matches'
|
import Matches from './pages/Matches'
|
||||||
import Standings from './pages/Standings'
|
import Standings from './pages/Standings'
|
||||||
import { adminRoutes } from './admin/routes'
|
import { adminRoutes } from './admin/routes'
|
||||||
|
|
||||||
/** 报眉日期行 */
|
|
||||||
function dateLine(): string {
|
|
||||||
return new Date().toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
weekday: 'long',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
<header className="masthead-rule">
|
<Masthead active="standings" />
|
||||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
|
||||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
|
||||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
|
||||||
先知
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
|
||||||
<span>{dateLine()}</span>
|
|
||||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
|
||||||
<Link to="/" className="text-ink-500 hover:text-press transition-colors">
|
|
||||||
比赛 / 预测
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
|
||||||
评估
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
|
||||||
<span aria-hidden="true">⚙</span> 管理
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
{children}
|
{children}
|
||||||
@@ -70,30 +39,8 @@ function StandingsLayout({ children }: { children: React.ReactNode }) {
|
|||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-paper-50">
|
<div className="min-h-screen bg-paper-50">
|
||||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接 ── */}
|
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接(共用 Masthead) ── */}
|
||||||
<header className="masthead-rule">
|
<Masthead active="home" />
|
||||||
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
|
||||||
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
|
||||||
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
|
||||||
先知
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
|
||||||
<span>{dateLine()}</span>
|
|
||||||
<nav className="flex items-center gap-4" aria-label="页面导航">
|
|
||||||
<Link to="/standings" className="text-ink-500 hover:text-press transition-colors">
|
|
||||||
积分榜
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin/eval" className="text-ink-500 hover:text-press transition-colors">
|
|
||||||
评估
|
|
||||||
</Link>
|
|
||||||
<Link to="/admin" className="flex items-center gap-1 text-ink-500 hover:text-press transition-colors">
|
|
||||||
<span aria-hidden="true">⚙</span> 管理
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||||
<Matches />
|
<Matches />
|
||||||
|
|||||||
@@ -370,6 +370,17 @@ export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
|||||||
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集任务历史列表(GET /admin/ingest/jobs,最新在前)
|
||||||
|
*/
|
||||||
|
export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise<IngestJob[]> {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (params.limit != null) q.set('limit', String(params.limit))
|
||||||
|
if (params.status) q.set('status', params.status)
|
||||||
|
const qs = q.toString()
|
||||||
|
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 比赛详情(含最近预测摘要)
|
* 比赛详情(含最近预测摘要)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+18
-17
@@ -20,18 +20,25 @@ export interface NavItem {
|
|||||||
hideFromSidebar?: boolean
|
hideFromSidebar?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 */
|
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。
|
||||||
|
*
|
||||||
|
* 分组按用户心智模型: 数据(往里灌) → 预测(算出来) → 系统(保证它活着)。
|
||||||
|
* 「预测历史/回测/评估」同属预测生命周期,不再挂在「数据流水线」名下。
|
||||||
|
*/
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
|
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
|
||||||
{ to: '/admin/collection', label: '数据采集', group: '数据流水线', icon: 'collection' },
|
// ── 数据:采集、核对、管线内部视图 ──
|
||||||
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线', icon: 'chart' },
|
{ to: '/admin/collection', label: '数据采集', group: '数据', icon: 'collection' },
|
||||||
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true },
|
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据', icon: 'eval' },
|
||||||
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' },
|
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据', icon: 'chart', hideFromSidebar: true },
|
||||||
{ to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' },
|
// ── 预测:历史 → 回测 → 评估闭环 ──
|
||||||
{ to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' },
|
{ to: '/admin/predictions', label: '预测历史', group: '预测', icon: 'target' },
|
||||||
{ to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' },
|
{ to: '/admin/backtest', label: '回测', group: '预测', icon: 'repeat' },
|
||||||
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
{ to: '/admin/eval', label: '评估', group: '预测', icon: 'eval' },
|
||||||
|
// ── 系统:活着吗、发生了什么、怎么调 ──
|
||||||
|
{ to: '/admin/monitoring', label: '监控', group: '系统', icon: 'monitor' },
|
||||||
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
|
||||||
|
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
|
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */
|
||||||
@@ -42,11 +49,6 @@ export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
|||||||
NAV_ITEMS.map(i => [i.to, i.label]),
|
NAV_ITEMS.map(i => [i.to, i.label]),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */
|
|
||||||
const SIDEBAR_GROUP_TITLES: Record<string, string> = {
|
|
||||||
系统: '系统设置',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
|
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
|
||||||
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
|
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
|
||||||
@@ -55,13 +57,12 @@ export const NAV_SECTIONS = (() => {
|
|||||||
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
|
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
|
||||||
const titles: string[] = []
|
const titles: string[] = []
|
||||||
for (const i of sidebarItems) {
|
for (const i of sidebarItems) {
|
||||||
const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group
|
if (!titles.includes(i.group)) titles.push(i.group)
|
||||||
if (!titles.includes(title)) titles.push(title)
|
|
||||||
}
|
}
|
||||||
return titles.map(title => ({
|
return titles.map(title => ({
|
||||||
title,
|
title,
|
||||||
items: sidebarItems
|
items: sidebarItems
|
||||||
.filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title)
|
.filter(i => i.group === title)
|
||||||
.map(({ to, label, icon }) => ({ to, label, icon })),
|
.map(({ to, label, icon }) => ({ to, label, icon })),
|
||||||
}))
|
}))
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -11,16 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal'
|
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal'
|
||||||
import type { IngestJob, League } from '../types'
|
import type { IngestJob, League } from '../types'
|
||||||
import type { CollectionRequest } from '../types'
|
import type { CollectionRequest } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||||
|
|
||||||
|
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
||||||
const TASKS = [
|
const TASKS = [
|
||||||
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '⚽' },
|
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '◈' },
|
||||||
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' },
|
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '◇' },
|
||||||
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' },
|
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '▤' },
|
||||||
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' },
|
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⇉' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
|
||||||
@@ -73,6 +74,16 @@ export default function CollectionPage() {
|
|||||||
|
|
||||||
useEffect(() => () => stopPolling(), [stopPolling])
|
useEffect(() => () => stopPolling(), [stopPolling])
|
||||||
|
|
||||||
|
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
|
||||||
|
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
|
||||||
|
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
||||||
|
const loadRecentJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRecentJobs(await fetchIngestJobs({ limit: 10 }))
|
||||||
|
} catch { /* 历史列表失败不影响主流程 */ }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { loadRecentJobs() }, [loadRecentJobs])
|
||||||
|
|
||||||
const startJobPolling = useCallback((id: string) => {
|
const startJobPolling = useCallback((id: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
@@ -82,12 +93,13 @@ export default function CollectionPage() {
|
|||||||
if (TERMINAL_STATUSES.has(job.status)) {
|
if (TERMINAL_STATUSES.has(job.status)) {
|
||||||
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
loadRecentJobs() // 终态后刷新历史列表
|
||||||
}
|
}
|
||||||
} catch { /* 单次轮询失败不影响后续 */ }
|
} catch { /* 单次轮询失败不影响后续 */ }
|
||||||
}
|
}
|
||||||
tick()
|
tick()
|
||||||
pollRef.current = setInterval(tick, 3_000)
|
pollRef.current = setInterval(tick, 3_000)
|
||||||
}, [stopPolling])
|
}, [stopPolling, loadRecentJobs])
|
||||||
|
|
||||||
const isEventsTask = task === 'events' || task === 'all'
|
const isEventsTask = task === 'events' || task === 'all'
|
||||||
|
|
||||||
@@ -183,9 +195,9 @@ export default function CollectionPage() {
|
|||||||
key={t.value}
|
key={t.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTask(t.value)}
|
onClick={() => setTask(t.value)}
|
||||||
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
|
className={`border px-3 py-2 text-left text-xs transition-colors ${
|
||||||
task === t.value
|
task === t.value
|
||||||
? 'border-brand-500 bg-brand-50 text-brand-700'
|
? 'border-press bg-press-wash text-press'
|
||||||
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
: 'border-ink-200 text-ink-600 hover:border-ink-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -321,8 +333,8 @@ export default function CollectionPage() {
|
|||||||
)}
|
)}
|
||||||
{taskStatus === 'done' && (
|
{taskStatus === 'done' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2 text-xs text-emerald-700">
|
<div className="flex items-center gap-2 text-xs text-ok-700">
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
<span className="inline-block h-2 w-2 rounded-full bg-ok-500" />
|
||||||
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
<span>采集完成{jobId ? `(job ${jobId.slice(0, 8)}…)` : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
{summary && <p className="text-2xs text-ink-500">{summary.detail}</p>}
|
||||||
@@ -345,6 +357,41 @@ export default function CollectionPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近任务历史 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader
|
||||||
|
title="最近任务"
|
||||||
|
description="后台采集任务执行历史(最新在前)"
|
||||||
|
action={<button onClick={loadRecentJobs} className="btn btn-sm">刷新</button>}
|
||||||
|
/>
|
||||||
|
<CardBody className="px-0">
|
||||||
|
{recentJobs === null ? (
|
||||||
|
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">加载中…</p>
|
||||||
|
) : recentJobs.length === 0 ? (
|
||||||
|
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">还没有任务记录,触发一次采集后这里会出现历史。</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{recentJobs.map(j => {
|
||||||
|
const st = j.status
|
||||||
|
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-warn-500 animate-pulse'
|
||||||
|
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
|
||||||
|
return (
|
||||||
|
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
||||||
|
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} />
|
||||||
|
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
|
||||||
|
<span className="text-2xs text-ink-600">{label}</span>
|
||||||
|
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
|
||||||
|
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||||
|
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 数据源说明 */}
|
{/* 数据源说明 */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="采集任务说明" />
|
<CardHeader title="采集任务说明" />
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 系统配置管理页面(报刊风)
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
|
|
||||||
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
|
|
||||||
* - 配置修改指南
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import { fetchSystemConfig } from '../dal'
|
|
||||||
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 [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)
|
|
||||||
try {
|
|
||||||
const data = await fetchSystemConfig()
|
|
||||||
setConfig(data)
|
|
||||||
} catch {
|
|
||||||
setConfig([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadConfig()
|
|
||||||
fetchAuthState()
|
|
||||||
.then(s => setPasswordOrigin(s.password_origin ?? null))
|
|
||||||
.catch(() => setPasswordOrigin(null))
|
|
||||||
}, [loadConfig])
|
|
||||||
|
|
||||||
async function handleChangePassword(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-6">
|
|
||||||
<SectionHeader
|
|
||||||
title="系统配置"
|
|
||||||
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 登录与鉴权 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="登录与鉴权"
|
|
||||||
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* 快速导航 */}
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
<a
|
|
||||||
href="/admin/data-sources"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">数据源配置</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">管理采集源 API Key</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/llm-config"
|
|
||||||
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-sm font-bold text-ink-900">LLM 配置</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-500">管理模型连接与统计</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 配置列表 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="当前配置"
|
|
||||||
description="脱敏展示,实际值在服务器 .env 文件中"
|
|
||||||
action={
|
|
||||||
<button onClick={loadConfig} disabled={loading} className="btn btn-sm">
|
|
||||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody className="px-0 sm:px-0">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3 px-4 sm:px-5">
|
|
||||||
{[1, 2, 3, 4, 5].map(i => (
|
|
||||||
<SkeletonBlock key={i} className="h-9 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : config.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
{config.map(item => (
|
|
||||||
<div
|
|
||||||
key={item.key}
|
|
||||||
className="flex flex-col gap-1 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_minmax(0,2fr)] sm:items-baseline sm:gap-4 sm:px-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-xs text-ink-800">{item.key}</span>
|
|
||||||
{item.is_sensitive && <Badge status="warning">敏感</Badge>}
|
|
||||||
</div>
|
|
||||||
<div className="break-all font-mono text-2xs text-ink-500">{item.value_masked}</div>
|
|
||||||
<div className="text-2xs text-ink-400">{item.description}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-8 text-center text-xs text-ink-400">无法加载配置信息</p>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 修改指南 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="修改配置指南" />
|
|
||||||
<CardBody className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">通过 SSH 修改 .env</h4>
|
|
||||||
<pre className="overflow-x-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-700">
|
|
||||||
{`# 连接到部署主机
|
|
||||||
ssh user@your-server-ip
|
|
||||||
|
|
||||||
# 进入项目目录
|
|
||||||
cd /vol2/1000/Docker/Profeto
|
|
||||||
|
|
||||||
# 编辑 .env 文件
|
|
||||||
nano .env
|
|
||||||
|
|
||||||
# 修改后重启后端服务
|
|
||||||
docker compose restart api
|
|
||||||
|
|
||||||
# 查看日志确认生效
|
|
||||||
docker compose logs -f api`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">常用配置项说明</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[
|
|
||||||
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
|
|
||||||
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
|
|
||||||
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
|
|
||||||
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
|
|
||||||
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
|
|
||||||
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
|
|
||||||
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
|
|
||||||
].map(([key, desc]) => (
|
|
||||||
<div key={key} className="flex items-start gap-2.5">
|
|
||||||
<code className="flex-shrink-0 border border-ink-200 bg-paper-100 px-1.5 py-0.5 font-mono text-2xs text-ink-800">
|
|
||||||
{key}
|
|
||||||
</code>
|
|
||||||
<span className="text-xs leading-relaxed text-ink-600">{desc}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,137 +1,199 @@
|
|||||||
/**
|
/**
|
||||||
* Admin 后台 - 仪表盘(报刊风)
|
* Admin 后台 - 仪表盘(报刊风·待办驱动)
|
||||||
*
|
*
|
||||||
* 展示:
|
* 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
|
||||||
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量)
|
* 而不是扫描一堆常驻数字。
|
||||||
* - 近期预测活动(24h / 7d / 总计)
|
* - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
|
||||||
* - 快捷操作入口(带工作流引导)
|
* 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
|
||||||
|
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
|
||||||
|
* - 数据概览合并为一卡:预测活动 + 各表数据量
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal'
|
||||||
|
import type { DataCompletenessResponse, IngestFailureItem } from '../dal'
|
||||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
|
||||||
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components'
|
import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
|
||||||
|
|
||||||
/** 工作流步骤卡片 */
|
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' },
|
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
|
||||||
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' },
|
{ to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
|
||||||
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' },
|
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
interface TodoItem {
|
||||||
|
key: string
|
||||||
|
count: number
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
/** 无上限确认时数字近似,展示为 N+ */
|
||||||
|
approx?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||||
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
|
||||||
const [dash, setDash] = useState<DashboardStats | null>(null)
|
const [dash, setDash] = useState<DashboardStats | null>(null)
|
||||||
|
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||||
|
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
|
||||||
|
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const [s, i, d] = await Promise.allSettled([
|
// 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
|
||||||
|
const [s, i, d, f, c, p] = await Promise.allSettled([
|
||||||
fetchAdminStats(),
|
fetchAdminStats(),
|
||||||
fetchIngestStatus(),
|
fetchIngestStatus(),
|
||||||
fetchDashboard(),
|
fetchDashboard(),
|
||||||
|
fetchIngestFailures(),
|
||||||
|
fetchDataCompleteness(),
|
||||||
|
fetchPredictions(100),
|
||||||
])
|
])
|
||||||
if (s.status === 'fulfilled') setStats(s.value)
|
if (s.status === 'fulfilled') setStats(s.value)
|
||||||
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
if (i.status === 'fulfilled') setIngest(i.value.sources)
|
||||||
if (d.status === 'fulfilled') setDash(d.value)
|
if (d.status === 'fulfilled') setDash(d.value)
|
||||||
|
if (f.status === 'fulfilled') setFailures(f.value)
|
||||||
|
if (c.status === 'fulfilled') setCompleteness(c.value)
|
||||||
|
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// ── 待办计算 ──
|
||||||
|
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
|
||||||
|
const missingStatsLeagues = completeness?.leagues.filter(
|
||||||
|
l => l.matches.finished > 0 && l.stats.rows === 0,
|
||||||
|
).length ?? 0
|
||||||
|
const missingStandingsLeagues = completeness?.leagues.filter(
|
||||||
|
l => l.matches.total > 0 && l.standings.rows === 0,
|
||||||
|
).length ?? 0
|
||||||
|
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
|
||||||
|
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
|
||||||
|
const settleable = recentPreds.filter(
|
||||||
|
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
|
||||||
|
).length
|
||||||
|
|
||||||
|
const todos: TodoItem[] = [
|
||||||
|
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
|
||||||
|
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
|
||||||
|
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
|
||||||
|
].filter((t): t is TodoItem => t !== false)
|
||||||
|
|
||||||
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
|
||||||
const bzzoiro = sourceByName['bzzoiro']
|
const bzzoiro = sourceByName['bzzoiro']
|
||||||
|
const hasMatches = (stats?.matches?.total ?? 0) > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── 工作流引导(采集 → 预测 → 评估) ── */}
|
{/* ── 待办行:只有真有待办才出现 ── */}
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
{loading ? (
|
||||||
{STEPS.map((s, i) => (
|
<SkeletonBlock className="h-14 w-full" />
|
||||||
<a
|
) : todos.length > 0 ? (
|
||||||
key={s.to}
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
href={s.to}
|
{todos.map(t => (
|
||||||
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
<Link
|
||||||
>
|
key={t.key}
|
||||||
<div className="flex items-center gap-2.5">
|
to={t.to}
|
||||||
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
||||||
{s.step}
|
>
|
||||||
|
<span className="font-serif text-2xl font-bold tabular-nums text-press">
|
||||||
|
{t.count}{t.approx ? '+' : ''}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
<span className="text-xs text-ink-700">{t.label}</span>
|
||||||
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
<span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true">→</span>
|
||||||
</div>
|
</Link>
|
||||||
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
))}
|
||||||
{i < STEPS.length - 1 && <span className="sr-only">下一步</span>}
|
</div>
|
||||||
</a>
|
) : (
|
||||||
))}
|
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
||||||
</div>
|
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
||||||
|
流水线无待办:没有失败记录、数据缺口或待结算预测。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 数据源健康一览 ── */}
|
{/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
|
||||||
|
{!loading && !hasMatches && (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{STEPS.map(s => (
|
||||||
|
<Link
|
||||||
|
key={s.to}
|
||||||
|
to={s.to}
|
||||||
|
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
|
||||||
|
{s.step}
|
||||||
|
</span>
|
||||||
|
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
|
||||||
|
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="数据源健康"
|
title="数据源"
|
||||||
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)"
|
description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
|
||||||
/>
|
/>
|
||||||
<CardBody className="px-0">
|
<CardBody className="px-0">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-2 px-4 sm:px-5">
|
<div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
|
||||||
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
(() => {
|
||||||
{[
|
const st = bzzoiro
|
||||||
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro },
|
const hasData = st && st.recent_count > 0
|
||||||
].map(({ name, label, st }) => {
|
const keyOk = st?.key_configured !== false
|
||||||
const hasData = st && st.recent_count > 0
|
return (
|
||||||
const keyOk = st?.key_configured !== false
|
<div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
|
||||||
return (
|
<span className="text-xs font-medium text-ink-700">Bzzoiro</span>
|
||||||
<div key={name} className="flex items-center justify-between border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
|
<span className="flex items-center gap-3 text-2xs">
|
||||||
<span className="text-xs font-medium text-ink-700">{label}</span>
|
{hasData ? (
|
||||||
<span className="flex items-center gap-3 text-2xs">
|
<>
|
||||||
{hasData ? (
|
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</span>
|
||||||
<>
|
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
|
||||||
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</span>
|
</>
|
||||||
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
|
) : keyOk ? (
|
||||||
</>
|
<span className="text-ink-400">无数据</span>
|
||||||
) : keyOk ? (
|
) : (
|
||||||
<span className="text-ink-400">无数据</span>
|
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
||||||
) : (
|
)}
|
||||||
<span className="text-press">未配置 Key</span>
|
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
|
||||||
)}
|
</span>
|
||||||
<span
|
</div>
|
||||||
aria-hidden="true"
|
)
|
||||||
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`}
|
})()
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── 近期预测活动 ── */}
|
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" />
|
<CardHeader title="数据概览" description="预测调用量与各表数据量" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
{stats ? (
|
{stats ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
||||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
<div className="mt-1 text-2xs text-ink-400">预测 · 近 24 小时</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
||||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
<div className="mt-1 text-2xs text-ink-400">预测 · 近 7 天</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
||||||
<div className="mt-1 text-2xs text-ink-400">累计</div>
|
<div className="mt-1 text-2xs text-ink-400">预测 · 累计</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
|
|
||||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pctColor(pct: number): string {
|
function pctColor(pct: number): string {
|
||||||
if (pct >= 80) return 'bg-emerald-500'
|
if (pct >= 80) return 'bg-ok-500'
|
||||||
if (pct >= 50) return 'bg-amber-500'
|
if (pct >= 50) return 'bg-warn-500'
|
||||||
return 'bg-rose-500'
|
return 'bg-bad-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据问题描述生成可操作的修复链接 */
|
/** 根据问题描述生成可操作的修复链接 */
|
||||||
|
|||||||
@@ -1,376 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
import {
|
|
||||||
fetchDataSourceStatuses,
|
|
||||||
fetchIngestStatus,
|
|
||||||
fetchAdminStats,
|
|
||||||
fetchKeyRingStatus,
|
|
||||||
resetKeyRingCooldown,
|
|
||||||
updateSetting,
|
|
||||||
clearSetting,
|
|
||||||
testDataSourceConnection,
|
|
||||||
} from '../dal'
|
|
||||||
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
|
|
||||||
import type { KeyRingStatusResponse } from '../dal'
|
|
||||||
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 [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
|
|
||||||
const [ingestLoading, setIngestLoading] = useState(true)
|
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
|
||||||
|
|
||||||
const [testingSource, setTestingSource] = useState<string | null>(null)
|
|
||||||
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 [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
|
|
||||||
const [ringLoading, setRingLoading] = useState(false)
|
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setLoadError('')
|
|
||||||
try {
|
|
||||||
setSources(await fetchDataSourceStatuses())
|
|
||||||
} catch (err) {
|
|
||||||
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
|
|
||||||
setSources([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 健康状态(只读,与配置加载并行;失败不阻塞配置页)
|
|
||||||
const loadIngest = useCallback(async () => {
|
|
||||||
setIngestLoading(true)
|
|
||||||
try {
|
|
||||||
const { sources } = await fetchIngestStatus()
|
|
||||||
setIngestStats(Object.fromEntries(sources.map(x => [x.name, x])))
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
} finally {
|
|
||||||
setIngestLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 管理区统计(只读)
|
|
||||||
const loadStats = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setStats(await fetchAdminStats())
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Key Ring 状态(只读)
|
|
||||||
const loadKeyRing = useCallback(async () => {
|
|
||||||
setRingLoading(true)
|
|
||||||
try {
|
|
||||||
setKeyRing(await fetchKeyRingStatus())
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
} finally {
|
|
||||||
setRingLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSources()
|
|
||||||
loadIngest()
|
|
||||||
loadStats()
|
|
||||||
loadKeyRing()
|
|
||||||
}, [loadSources, loadIngest, loadStats, loadKeyRing])
|
|
||||||
|
|
||||||
async function handleTest(sourceName: string) {
|
|
||||||
setTestingSource(sourceName)
|
|
||||||
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
|
|
||||||
try {
|
|
||||||
const result = await testDataSourceConnection(sourceName)
|
|
||||||
setTestResults(prev => ({ ...prev, [sourceName]: result }))
|
|
||||||
} catch (err: unknown) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 渲染数据源健康块(最近采集 + 异常提示)
|
|
||||||
function renderHealth(sourceName: string) {
|
|
||||||
const st = ingestStats[sourceName]
|
|
||||||
if (!st) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-ink-400">最近成功采集</span>
|
|
||||||
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录'
|
|
||||||
const issues: string[] = []
|
|
||||||
if (st.status === 'key_not_configured') issues.push('未配置 API Key')
|
|
||||||
else if (st.status === 'no_data') issues.push('本地无数据,建议补采')
|
|
||||||
if (st.last_failure) issues.push('近期有采集失败')
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5 border-t border-ink-200 pt-3">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-ink-400">最近成功采集</span>
|
|
||||||
<span className="text-ink-600">{ago}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-ink-400">已入库(近期)</span>
|
|
||||||
<span className="text-ink-600">{st.recent_count.toLocaleString()} 条</span>
|
|
||||||
</div>
|
|
||||||
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
|
|
||||||
{issues.length > 0 && (
|
|
||||||
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
|
|
||||||
{issues.join(' / ')} — 请前往「数据采集」补采
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{st.last_failure && (
|
|
||||||
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
|
|
||||||
最近失败: {st.last_failure.detail.slice(0, 60)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResetCooldown() {
|
|
||||||
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] : '重置失败' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<SectionHeader
|
|
||||||
title="数据源管理"
|
|
||||||
description="数据采集源的 API 配置、健康状态与连通性测试。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loadError && (
|
|
||||||
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 数据源卡片 */}
|
|
||||||
{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="space-y-3">
|
|
||||||
<SkeletonBlock className="h-4 w-24" />
|
|
||||||
<SkeletonBlock className="h-3 w-32" />
|
|
||||||
<SkeletonBlock className="h-8 w-full" />
|
|
||||||
</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]
|
|
||||||
const cardKeys = source.settings.map(s => s.key)
|
|
||||||
return (
|
|
||||||
<Card key={source.name}>
|
|
||||||
<CardBody className="space-y-4">
|
|
||||||
<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.key_configured ? 'success' : 'error'}>
|
|
||||||
{source.key_configured ? '已就绪' : '缺配置'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
|
|
||||||
|
|
||||||
{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={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 数据源健康:最近采集 + 异常提示 */}
|
|
||||||
{renderHealth(source.name)}
|
|
||||||
|
|
||||||
{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}
|
|
||||||
className="btn btn-sm w-full"
|
|
||||||
>
|
|
||||||
{testingSource === source.name ? (<><Spinner /> 测试中</>) : '测试连接'}
|
|
||||||
</button>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* API Key 轮换环状态 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="API Key 轮换环"
|
|
||||||
description={keyRing?.has_multiple
|
|
||||||
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
|
|
||||||
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
|
|
||||||
}
|
|
||||||
action={
|
|
||||||
<button
|
|
||||||
onClick={handleResetCooldown}
|
|
||||||
disabled={ringLoading}
|
|
||||||
className="btn-sm btn-outline"
|
|
||||||
>
|
|
||||||
重置冷却
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
{ringLoading && !keyRing ? (
|
|
||||||
<SkeletonBlock className="h-10 w-full" />
|
|
||||||
) : 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>
|
|
||||||
)}
|
|
||||||
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
|
||||||
在「Bzzoiro」配置项中用<b>逗号 / 分号 / 换行</b>分隔多个 key 即可启用轮换。遇到 429 自动标记当前 key 为冷却并立即切换到下一个 key;
|
|
||||||
全部 key 冷却时等待最早恢复的 key。「重置冷却」可紧急恢复所有 key。
|
|
||||||
</p>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 近期活动统计(只读) */}
|
|
||||||
{stats && stats.predictions && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
|
|
||||||
<CardBody>
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">近 24 小时</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">近 7 天</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
|
|
||||||
<div className="mt-1 text-2xs text-ink-400">总计</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="配置说明" />
|
|
||||||
<CardBody>
|
|
||||||
<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」删除覆盖值。
|
|
||||||
</p>
|
|
||||||
<p className="border-l-2 border-ink-300 pl-3">
|
|
||||||
「最近成功采集」为只读健康快照(不触发采集);近似数据已在行内标注。伤停源会区分「未配置 Key / 无数据 / 有数据」。
|
|
||||||
</p>
|
|
||||||
<p className="border-l-2 border-ink-300 pl-3">
|
|
||||||
「测试连接」真实请求上游一次;异常提示会引导前往「数据采集」补采,避免在空数据上误操作。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - LLM 配置管理页面(报刊风)
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定)
|
|
||||||
* - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用)
|
|
||||||
* - 显示 LLM 使用统计(从预测记录聚合)
|
|
||||||
* - 可用模型列表
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
|
||||||
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 LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
try {
|
|
||||||
const data = await fetchLLMUsageStats()
|
|
||||||
setStats(data)
|
|
||||||
} catch {
|
|
||||||
setStats(null)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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)
|
|
||||||
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="大语言模型连接状态与使用统计。模型切换通过修改 .env 并重启服务完成。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* LLM 连接配置 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="连接配置"
|
|
||||||
description="保存到数据库并立即生效,优先于服务器 .env"
|
|
||||||
action={
|
|
||||||
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
|
|
||||||
{settingsLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
{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 && (
|
|
||||||
<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={loadStats} disabled={loading} className="btn btn-sm">
|
|
||||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<SkeletonBlock className="h-16 w-full" />
|
|
||||||
<SkeletonBlock className="h-16 w-full" />
|
|
||||||
</div>
|
|
||||||
) : stats ? (
|
|
||||||
<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">
|
|
||||||
{stats.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">
|
|
||||||
{stats.avg_latency_ms > 0 ? `${(stats.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">
|
|
||||||
{stats.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>
|
|
||||||
|
|
||||||
{/* 专家与终裁独立配置 */}
|
|
||||||
<AgentLLMCard />
|
|
||||||
|
|
||||||
{/* 最近预测 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="最近预测记录" />
|
|
||||||
<CardBody className="px-0 sm:px-0">
|
|
||||||
{loading ? (
|
|
||||||
<div className="space-y-2 px-4 sm:px-5">
|
|
||||||
{[1, 2, 3].map(i => (
|
|
||||||
<SkeletonBlock key={i} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : stats && stats.recent_predictions.length > 0 ? (
|
|
||||||
<div>
|
|
||||||
{stats.recent_predictions.map(p => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-baseline gap-3">
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
|
||||||
<span className="text-xs text-ink-800">比赛 #{p.match_id}</span>
|
|
||||||
<span className="font-mono text-2xs text-ink-500">{p.model}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">
|
|
||||||
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
|
|
||||||
</span>
|
|
||||||
{p.status === 'success' ? <Badge status="success">成功</Badge> : <Badge status="error">失败</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="py-8 text-center text-xs text-ink-400">暂无预测记录</p>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -85,7 +85,7 @@ export default function LogsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统日志"
|
title="系统日志"
|
||||||
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。"
|
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
{error && <Alert kind="error" title="无法加载日志" message={error} />}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function PredictionHistoryPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody className="text-center">
|
<CardBody className="text-center">
|
||||||
<p className="text-2xl font-bold text-emerald-700">{hitCount}</p>
|
<p className="text-2xl font-bold text-ok-700">{hitCount}</p>
|
||||||
<p className="text-xs text-ink-500">命中</p>
|
<p className="text-xs text-ink-500">命中</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -194,7 +194,7 @@ export default function PredictionHistoryPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{p.pred_1x2 ? (
|
{p.pred_1x2 ? (
|
||||||
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
<span className={`inline-block border px-1.5 py-0.5 text-2xs ${
|
||||||
predHit === true ? 'border-emerald-300 text-emerald-700 bg-emerald-50' :
|
predHit === true ? 'border-ok-300 text-ok-700 bg-ok-50' :
|
||||||
predHit === false ? 'border-press text-press bg-press-wash' :
|
predHit === false ? 'border-press text-press bg-press-wash' :
|
||||||
'border-ink-200 text-ink-600'
|
'border-ink-200 text-ink-600'
|
||||||
}`}>
|
}`}>
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin 后台 - 预测管理页面(报刊风)
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 触发预测(选择比赛 + 模式)
|
|
||||||
* - 结算:录入实际比分,写入评估(接 /eval/settle)
|
|
||||||
* - 预测记录列表:可展开查看终裁理由与专家摘要
|
|
||||||
*
|
|
||||||
* 响应式布局: 移动端单列,桌面端双列
|
|
||||||
*/
|
|
||||||
|
|
||||||
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'
|
|
||||||
import { AgentWeightsBar } from '../components'
|
|
||||||
|
|
||||||
const AGENT_LABELS: Record<string, string> = {
|
|
||||||
h2h: '历史交锋分析专家',
|
|
||||||
form: '近期状态分析专家',
|
|
||||||
stats: '攻防数据分析专家',
|
|
||||||
home_away: '主客因素分析专家',
|
|
||||||
injuries: '阵容完整性分析专家',
|
|
||||||
}
|
|
||||||
|
|
||||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
|
||||||
|
|
||||||
function fmtTime(s?: string | null): string {
|
|
||||||
if (!s) return '—'
|
|
||||||
const d = new Date(s)
|
|
||||||
return isNaN(d.getTime())
|
|
||||||
? s
|
|
||||||
: d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PredictionsPage() {
|
|
||||||
const [matches, setMatches] = useState<Match[]>([])
|
|
||||||
const [predictions, setPredictions] = useState<Prediction[]>([])
|
|
||||||
const [matchId, setMatchId] = useState('')
|
|
||||||
const [mode, setMode] = useState<'single' | 'multi'>('multi')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// 结算表单
|
|
||||||
const [settleId, setSettleId] = useState('')
|
|
||||||
const [homeGoals, setHomeGoals] = useState('')
|
|
||||||
const [awayGoals, setAwayGoals] = useState('')
|
|
||||||
const [settling, setSettling] = useState(false)
|
|
||||||
const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null)
|
|
||||||
|
|
||||||
const refreshPredictions = useCallback(async () => {
|
|
||||||
const list = await fetchPredictions(50)
|
|
||||||
setPredictions(list)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refreshPredictions()
|
|
||||||
fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
|
|
||||||
}, [refreshPredictions])
|
|
||||||
|
|
||||||
/** match_id → 中文名对阵 */
|
|
||||||
const matchName = useMemo(() => {
|
|
||||||
const map = new Map<number, string>()
|
|
||||||
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, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}, [matches])
|
|
||||||
|
|
||||||
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
|
|
||||||
|
|
||||||
async function handlePredict(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!matchId) return
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
setSuccessMsg(null)
|
|
||||||
try {
|
|
||||||
await triggerPrediction({ match_id: parseInt(matchId), mode })
|
|
||||||
setSuccessMsg('预测任务已完成,记录已更新')
|
|
||||||
await refreshPredictions()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setError(err instanceof Error ? err.message : '预测失败')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsettled = predictions.filter(p => !p.settled)
|
|
||||||
|
|
||||||
async function handleSettle(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
const pid = parseInt(settleId)
|
|
||||||
const hg = parseInt(homeGoals)
|
|
||||||
const ag = parseInt(awayGoals)
|
|
||||||
if (!pid || isNaN(hg) || isNaN(ag)) return
|
|
||||||
setSettling(true)
|
|
||||||
setSettleMsg(null)
|
|
||||||
try {
|
|
||||||
await settlePrediction(pid, hg, ag)
|
|
||||||
setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' })
|
|
||||||
setSettleId('')
|
|
||||||
setHomeGoals('')
|
|
||||||
setAwayGoals('')
|
|
||||||
await refreshPredictions()
|
|
||||||
} catch (err: unknown) {
|
|
||||||
setSettleMsg({
|
|
||||||
kind: 'error',
|
|
||||||
text: err instanceof Error ? err.message : '结算失败',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setSettling(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const settleTarget = predictions.find(p => p.id === parseInt(settleId))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<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 text-ink-500">比赛</label>
|
|
||||||
<select
|
|
||||||
value={matchId}
|
|
||||||
onChange={e => setMatchId(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
>
|
|
||||||
<option value="">选择比赛</option>
|
|
||||||
{matches.map(m => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
|
||||||
<select
|
|
||||||
value={mode}
|
|
||||||
onChange={e => setMode(e.target.value as 'single' | 'multi')}
|
|
||||||
className="field w-full"
|
|
||||||
>
|
|
||||||
<option value="multi">多专家 (5 路 + 终裁,慢而稳)</option>
|
|
||||||
<option value="single">单次调用 (快)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
|
|
||||||
{successMsg && (
|
|
||||||
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !matchId}
|
|
||||||
className="btn btn-solid w-full"
|
|
||||||
>
|
|
||||||
{loading ? (<><Spinner /> 预测中,多专家模式约需 20-60 秒</>) : '触发预测'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 结算 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader
|
|
||||||
title="预测结算"
|
|
||||||
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
|
|
||||||
/>
|
|
||||||
<CardBody>
|
|
||||||
{unsettled.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-xs text-ink-400">
|
|
||||||
没有待结算的预测记录。预测完成后可在此录入实际比分。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleSettle} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">预测记录</label>
|
|
||||||
<select
|
|
||||||
value={settleId}
|
|
||||||
onChange={e => setSettleId(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
>
|
|
||||||
<option value="">选择待结算预测({unsettled.length} 条)</option>
|
|
||||||
{unsettled.map(p => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
#{p.id} {nameOf(p.match_id)} · 预测 {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settleTarget && (
|
|
||||||
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
|
|
||||||
预测:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
|
|
||||||
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
|
|
||||||
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">主队实际进球</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={30}
|
|
||||||
value={homeGoals}
|
|
||||||
onChange={e => setHomeGoals(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1.5 block text-xs text-ink-500">客队实际进球</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={30}
|
|
||||||
value={awayGoals}
|
|
||||||
onChange={e => setAwayGoals(e.target.value)}
|
|
||||||
className="field w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{settleMsg && (
|
|
||||||
<Alert
|
|
||||||
kind={settleMsg.kind}
|
|
||||||
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
|
|
||||||
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
|
|
||||||
onClose={() => setSettleMsg(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
|
|
||||||
className="btn btn-solid w-full"
|
|
||||||
>
|
|
||||||
{settling ? (<><Spinner /> 结算中</>) : '提交结算'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最近预测 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
|
|
||||||
<CardBody className="px-0 sm:px-0">
|
|
||||||
{predictions.length === 0 ? (
|
|
||||||
<p className="py-10 text-center text-xs text-ink-400">
|
|
||||||
暂无预测记录,触发预测后将在此显示
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{predictions.map(p => {
|
|
||||||
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
|
|
||||||
return (
|
|
||||||
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
|
|
||||||
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
|
|
||||||
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
|
|
||||||
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
|
|
||||||
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
|
|
||||||
</span>
|
|
||||||
<span className="text-2xs text-ink-500">
|
|
||||||
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
|
|
||||||
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
|
|
||||||
` · ${Math.round(p.subjective_confidence * 100)}%`}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto flex items-baseline gap-3">
|
|
||||||
{p.settled ? (
|
|
||||||
<Badge status="success">已结算</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge status="pending">未结算</Badge>
|
|
||||||
)}
|
|
||||||
{p.status === 'degraded' && (
|
|
||||||
<Badge status="error">降级·仅供参考</Badge>
|
|
||||||
)}
|
|
||||||
{p.status === 'failed' && (
|
|
||||||
<Badge status="error">预测失败</Badge>
|
|
||||||
)}
|
|
||||||
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
|
|
||||||
<p className="text-2xs text-ink-500">
|
|
||||||
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
|
|
||||||
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{p.reasoning && (
|
|
||||||
<blockquote className="border-l-2 border-press pl-4">
|
|
||||||
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
|
|
||||||
{p.reasoning}
|
|
||||||
</p>
|
|
||||||
</blockquote>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{p.agent_weights && Object.keys(p.agent_weights).length > 0 && (
|
|
||||||
<AgentWeightsBar weights={p.agent_weights} okCount={okAgents.length} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{p.agent_outputs && p.agent_outputs.length > 0 && (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{p.agent_outputs.map((a, i) => (
|
|
||||||
<li key={i} className="flex items-baseline gap-2.5 text-xs">
|
|
||||||
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
|
|
||||||
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
|
|
||||||
{a.probable_score && (
|
|
||||||
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
|
|
||||||
)}
|
|
||||||
<span className="text-2xs text-ink-400">
|
|
||||||
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{p.settled && (
|
|
||||||
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
|
|
||||||
实际比分 {p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
fetchSettings, updateSetting, clearSetting,
|
fetchSettings, updateSetting, clearSetting,
|
||||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||||
@@ -26,7 +27,24 @@ import AgentLLMCard from '../AgentLLMCard'
|
|||||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||||
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||||
|
|
||||||
|
/** 设置页分区(tab)。低频/高危操作靠后:安全放最后。 */
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'datasource', label: '数据源' },
|
||||||
|
{ id: 'llm', label: '大语言模型' },
|
||||||
|
{ id: 'schedules', label: '定时任务' },
|
||||||
|
{ id: 'security', label: '登录认证' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type TabId = (typeof TABS)[number]['id']
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
// tab 状态写入 URL(?tab=llm),可深链直达、刷新保持
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const rawTab = searchParams.get('tab')
|
||||||
|
const tab: TabId = TABS.some(t => t.id === rawTab) ? (rawTab as TabId) : 'datasource'
|
||||||
|
const setTab = (id: TabId) =>
|
||||||
|
setSearchParams(id === 'datasource' ? {} : { tab: id }, { replace: true })
|
||||||
|
|
||||||
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
|
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
|
||||||
const [settingsLoading, setSettingsLoading] = useState(true)
|
const [settingsLoading, setSettingsLoading] = useState(true)
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
@@ -229,12 +247,31 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="系统设置"
|
title="系统设置"
|
||||||
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。"
|
description="数据源、LLM、定时任务与登录认证。保存到数据库并立即生效,优先于 .env。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* ── 分区 tab(状态在 URL 上,可深链) ── */}
|
||||||
|
<div className="-mt-4 flex gap-5 overflow-x-auto border-b border-ink-200" role="tablist" aria-label="设置分区">
|
||||||
|
{TABS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`-mb-px flex-shrink-0 border-b-2 pb-2 text-sm transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? 'border-press font-bold text-press'
|
||||||
|
: 'border-transparent text-ink-500 hover:text-ink-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 1. 数据源 ── */}
|
{/* ── 1. 数据源 ── */}
|
||||||
|
{tab === 'datasource' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">数据源</h2>
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
@@ -280,11 +317,11 @@ export default function SettingsPage() {
|
|||||||
return (
|
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 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">
|
<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={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-warn-500' : 'bg-ok-500'}`} />
|
||||||
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
|
<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>}
|
{i === keyRing.active_index && <span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500">当前</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
|
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-warn-700' : 'text-ink-400'}`}>
|
||||||
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,10 +335,11 @@ export default function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 2. LLM ── */}
|
{/* ── 2. LLM ── */}
|
||||||
|
{tab === 'llm' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">大语言模型</h2>
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
@@ -372,10 +410,11 @@ export default function SettingsPage() {
|
|||||||
<AgentLLMCard />
|
<AgentLLMCard />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 3. 认证 ── */}
|
{/* ── 3. 认证 ── */}
|
||||||
|
{tab === 'security' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">登录认证</h2>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
|
||||||
<CardBody>
|
<CardBody>
|
||||||
@@ -413,10 +452,11 @@ export default function SettingsPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── 4. 定时任务 ── */}
|
{/* ── 4. 定时任务 ── */}
|
||||||
|
{tab === 'schedules' && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="section-head mb-3">定时任务</h2>
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="采集调度"
|
title="采集调度"
|
||||||
@@ -449,7 +489,7 @@ export default function SettingsPage() {
|
|||||||
<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 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-1 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<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={`inline-block h-2 w-2 rounded-full ${s.enabled ? 'bg-ok-500' : 'bg-ink-300'}`} />
|
||||||
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
<span className="text-xs font-medium text-ink-800">{s.id}</span>
|
||||||
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
<Badge status={s.enabled ? 'success' : 'muted'}>{s.task}</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,6 +531,7 @@ export default function SettingsPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
|
||||||
|
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
|
||||||
|
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
|
||||||
|
const [show, setShow] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => setShow(window.scrollY > threshold)
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [threshold])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
|
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center border border-ink-300 bg-paper-50 text-ink-600 transition-all duration-300 hover:border-ink-900 hover:bg-ink-900 hover:text-paper-50 ${
|
||||||
|
show ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
|
||||||
|
}`}
|
||||||
|
aria-label="回到顶部"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 报头组件(前台共用)。
|
||||||
|
*
|
||||||
|
* 此前 HomePage 与 StandingsLayout 各自复制一份报头,导航项已经漂移
|
||||||
|
* (首页有「积分榜」、积分榜页有「比赛/预测」),且无当前页高亮。
|
||||||
|
* 现在统一为 Masthead:
|
||||||
|
* - active 声明当前版面,对应导航项加印报红高亮 + aria-current
|
||||||
|
* - 「管理」入口改用与全站一致的线条 SVG(替代字符 ⚙)
|
||||||
|
* - 管理类入口带 title 提示,避免访客被无声踢到登录页
|
||||||
|
* 页脚文案各页不同,仍由调用方自行渲染。
|
||||||
|
*/
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export type MastheadActive = 'home' | 'standings'
|
||||||
|
|
||||||
|
function GearIcon() {
|
||||||
|
return (
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MastheadLink({
|
||||||
|
to,
|
||||||
|
active = false,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
/** 当前版面才高亮;管理类入口无高亮概念 */
|
||||||
|
active?: boolean
|
||||||
|
title?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
title={title}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={`transition-colors ${
|
||||||
|
active ? 'font-medium text-press' : 'text-ink-500 hover:text-press'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Masthead({ active }: { active: MastheadActive }) {
|
||||||
|
return (
|
||||||
|
<header className="masthead-rule">
|
||||||
|
<div className="mx-auto max-w-5xl px-5 sm:px-8">
|
||||||
|
<div className="border-b border-ink-900 py-5 text-center sm:py-6">
|
||||||
|
<h1 className="font-brush text-5xl text-ink-900 sm:text-6xl">
|
||||||
|
先知
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b border-ink-200 py-2 text-2xs text-ink-500">
|
||||||
|
<span>{new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })}</span>
|
||||||
|
<nav className="flex items-center gap-4" aria-label="页面导航">
|
||||||
|
<MastheadLink to="/" active={active === 'home'}>比赛 / 预测</MastheadLink>
|
||||||
|
<MastheadLink to="/standings" active={active === 'standings'}>积分榜</MastheadLink>
|
||||||
|
{/* 管理类页面对访客需要登录,入口处先说明,避免无声跳登录页 */}
|
||||||
|
<MastheadLink to="/admin/eval" title="评估页面向管理员开放,需登录">评估</MastheadLink>
|
||||||
|
<MastheadLink to="/admin" title="管理后台,需登录">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<GearIcon /> 管理
|
||||||
|
</span>
|
||||||
|
</MastheadLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -103,6 +103,28 @@
|
|||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
@apply border-press bg-transparent text-press hover:bg-press hover:text-paper-50;
|
||||||
}
|
}
|
||||||
|
/* 描边确认按钮:次级动作中需要比默认 btn 更明确轮廓的场合
|
||||||
|
(此前三处使用但从未定义,样式静默失效) */
|
||||||
|
.btn-outline {
|
||||||
|
@apply border-ink-900 bg-transparent text-ink-900
|
||||||
|
hover:border-press hover:bg-press-wash hover:text-press-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
|
||||||
|
.modal-overlay-enter {
|
||||||
|
animation: modal-fade 0.18s ease-out both;
|
||||||
|
}
|
||||||
|
.modal-panel-enter {
|
||||||
|
animation: modal-rise 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
@keyframes modal-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes modal-rise {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── 统一空态 ── */
|
/* ── 统一空态 ── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@@ -10,9 +10,11 @@
|
|||||||
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
||||||
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||||
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||||
|
import BackTop from '../components/BackTop'
|
||||||
import { useMatchesList } from './matches/hooks/useMatchesList'
|
import { useMatchesList } from './matches/hooks/useMatchesList'
|
||||||
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
||||||
import { useLeagues } from './matches/hooks/useLeagues'
|
import { useLeagues } from './matches/hooks/useLeagues'
|
||||||
@@ -38,13 +40,8 @@ export default function Matches() {
|
|||||||
loadMore,
|
loadMore,
|
||||||
} = useMatchesList({ onError: setError })
|
} = useMatchesList({ onError: setError })
|
||||||
|
|
||||||
const {
|
const { predictingId, prediction, predictionFor, predict, closePredict } =
|
||||||
predictingId,
|
useMatchPredict({ onError: setError })
|
||||||
prediction,
|
|
||||||
predictionFor,
|
|
||||||
predict,
|
|
||||||
closePredict,
|
|
||||||
} = useMatchPredict({ onError: setError })
|
|
||||||
|
|
||||||
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
// ── 详情展开(懒加载,只读,不触发 LLM) ──
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
@@ -52,16 +49,8 @@ export default function Matches() {
|
|||||||
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
const [contextMap, setContextMap] = useState<Record<number, MatchContextOut>>({})
|
||||||
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
const [detailLoading, setDetailLoading] = useState<number | null>(null)
|
||||||
|
|
||||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
|
||||||
const [showBackTop, setShowBackTop] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll)
|
|
||||||
}, [])
|
|
||||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
|
|
||||||
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
const leagueName = leagues.find(l => l.code === league)?.name ?? league
|
||||||
|
// 删除本地 showBackTop/scrollToTop,统一使用共享 BackTop 组件
|
||||||
|
|
||||||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||||
const isScheduledView = status === 'scheduled'
|
const isScheduledView = status === 'scheduled'
|
||||||
@@ -90,20 +79,46 @@ export default function Matches() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示
|
||||||
|
const leagueNavRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = leagueNavRef.current
|
||||||
|
if (!el) return
|
||||||
|
const update = () => setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 8)
|
||||||
|
update()
|
||||||
|
el.addEventListener('scroll', update, { passive: true })
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', update)
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
}
|
||||||
|
}, [leagues])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* ── 联赛版面切换 ── */}
|
{/* ── 联赛版面切换 ── */}
|
||||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-ink-900" aria-label="联赛">
|
<div className="relative">
|
||||||
{leagues.map(l => (
|
<nav
|
||||||
<button
|
ref={leagueNavRef}
|
||||||
key={l.code}
|
className="flex items-center gap-6 overflow-x-auto border-b border-ink-900"
|
||||||
onClick={() => setLeague(l.code)}
|
aria-label="联赛"
|
||||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
>
|
||||||
>
|
{leagues.map(l => (
|
||||||
{l.name}
|
<button
|
||||||
</button>
|
key={l.code}
|
||||||
))}
|
onClick={() => setLeague(l.code)}
|
||||||
</nav>
|
aria-current={league === l.code ? 'true' : undefined}
|
||||||
|
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||||
|
>
|
||||||
|
{l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
{canScrollRight && (
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-paper-50 to-transparent" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
{/* ── 第二行:状态 / 模式 / 日期 / 计数 / 刷新(小屏 flex-wrap) ── */}
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-ink-500 sm:gap-x-5">
|
||||||
@@ -120,7 +135,7 @@ export default function Matches() {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="ml-auto inline-flex items-center gap-3">
|
<span className="ml-auto inline-flex items-center gap-3 border-l border-ink-200 pl-4">
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
{isScheduledView && !showAllUpcoming && hasHiddenUpcoming
|
||||||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||||
@@ -203,9 +218,9 @@ export default function Matches() {
|
|||||||
<>
|
<>
|
||||||
<p className="empty-state-title">本版暂无赛程</p>
|
<p className="empty-state-title">本版暂无赛程</p>
|
||||||
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
<p className="empty-state-sub">请先通过「数据采集」导入 {leagueName} 的比赛数据</p>
|
||||||
<a href="/admin/collection" className="empty-state-action">
|
<Link to="/admin/collection" className="empty-state-action">
|
||||||
前往数据采集 <span aria-hidden="true">→</span>
|
前往数据采集 <span aria-hidden="true">→</span>
|
||||||
</a>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -265,18 +280,8 @@ export default function Matches() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* ── 回到顶部(共享组件,方角纸片风) ── */}
|
||||||
<button
|
<BackTop />
|
||||||
onClick={scrollToTop}
|
|
||||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
|
||||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
aria-label="回到顶部"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import BackTop from '../components/BackTop'
|
||||||
import { fetchStandings } from '../admin/dal'
|
import { fetchStandings } from '../admin/dal'
|
||||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||||
import { useLeagues } from './matches/hooks/useLeagues'
|
import { useLeagues } from './matches/hooks/useLeagues'
|
||||||
@@ -13,24 +14,24 @@ import { Spinner } from '../admin/components'
|
|||||||
|
|
||||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||||
// 欧战资格
|
// 欧战资格
|
||||||
'Champions League': { label: '欧冠区', cls: 'bg-emerald-100 text-emerald-700' },
|
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-emerald-100 text-emerald-700' },
|
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Europa League': { label: '欧联区', cls: 'bg-amber-100 text-amber-700' },
|
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
|
||||||
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||||
// 升级
|
// 升级
|
||||||
'Championship': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Promotion': { label: '升级区', cls: 'bg-emerald-100 text-emerald-700' },
|
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||||
'Promotion Group': { label: '升级组', cls: 'bg-emerald-100 text-emerald-700' },
|
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
|
||||||
// 降级
|
// 降级
|
||||||
'Relegation': { label: '降级区', cls: 'bg-rose-100 text-rose-700' },
|
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
|
||||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||||
'Relegation Group': { label: '降级组', cls: 'bg-rose-100 text-rose-700' },
|
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
|
||||||
// 附加赛
|
// 附加赛
|
||||||
'Playoffs': { label: '附加赛', cls: 'bg-amber-100 text-amber-700' },
|
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-amber-100 text-amber-700' },
|
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||||
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,7 @@ function zoneBadge(zone?: string | null) {
|
|||||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||||
function FormDots({ form }: { form?: string | null }) {
|
function FormDots({ form }: { form?: string | null }) {
|
||||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||||
const colorMap: Record<string, string> = { W: 'bg-emerald-500', D: 'bg-ink-300', L: 'bg-rose-500' }
|
const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex gap-0.5">
|
<span className="inline-flex gap-0.5">
|
||||||
{form.slice(0, 5).split('').map((c, i) => (
|
{form.slice(0, 5).split('').map((c, i) => (
|
||||||
@@ -62,17 +63,6 @@ export default function StandingsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [switching, setSwitching] = useState(false) // 切换联赛中
|
const [switching, setSwitching] = useState(false) // 切换联赛中
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showBackTop, setShowBackTop] = useState(false) // 回到顶部按钮显示态
|
|
||||||
|
|
||||||
// 监听滚动,超过 300px 显示回到顶部按钮
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => setShowBackTop(window.scrollY > 300)
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
|
|
||||||
const load = useCallback(async (code?: string) => {
|
const load = useCallback(async (code?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -134,7 +124,7 @@ export default function StandingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="border border-rose-300 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
<div className="border border-bad-300 bg-bad-50 px-4 py-3 text-sm text-bad-700">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -202,7 +192,7 @@ export default function StandingsPage() {
|
|||||||
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
<td className="text-center py-2 text-ink-500">{r.drawn}</td>
|
||||||
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
<td className="text-center py-2 text-ink-500">{r.lost}</td>
|
||||||
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
<td className="text-center py-2 text-ink-500">{r.goals_for}/{r.goals_against}</td>
|
||||||
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-emerald-600' : r.goal_diff < 0 ? 'text-rose-600' : 'text-ink-500'}`}>
|
<td className={`text-center py-2 ${r.goal_diff > 0 ? 'text-ok-600' : r.goal_diff < 0 ? 'text-bad-600' : 'text-ink-500'}`}>
|
||||||
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
{r.goal_diff > 0 ? `+${r.goal_diff}` : r.goal_diff}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
<td className="text-center py-2 font-bold text-ink-900">{r.points}</td>
|
||||||
@@ -221,18 +211,8 @@ export default function StandingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部(共享组件,方角纸片风) */}
|
||||||
<button
|
<BackTop />
|
||||||
onClick={scrollToTop}
|
|
||||||
className={`fixed bottom-6 right-6 z-40 flex h-10 w-10 items-center justify-center rounded-full border border-ink-200 bg-paper-50 text-ink-600 shadow-lg transition-all duration-300 hover:border-ink-400 hover:text-ink-900 ${
|
|
||||||
showBackTop ? 'translate-y-0 opacity-100' : 'translate-y-4 opacity-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
aria-label="回到顶部"
|
|
||||||
>
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 15l7-7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import TeamSideTag from '../../../components/TeamSideTag'
|
import TeamSideTag from '../../../components/TeamSideTag'
|
||||||
import type { Match, Prediction } from '../types'
|
import type { Match, Prediction } from '../types'
|
||||||
import { AGENT_LABELS } from '../types'
|
import { AGENT_LABELS } from '../types'
|
||||||
@@ -82,7 +82,7 @@ function PredictionPanel({
|
|||||||
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
||||||
|
|
||||||
<p className="text-center text-2xs text-ink-500">
|
<p className="text-center text-2xs text-ink-500">
|
||||||
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
|
||||||
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -161,7 +161,8 @@ function PredictProgress() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。
|
||||||
|
关闭窗口将停止进度显示,后台任务可能仍在执行并消耗额度。
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-center text-2xs text-ink-300">
|
<p className="mt-1 text-center text-2xs text-ink-300">
|
||||||
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
||||||
@@ -185,17 +186,52 @@ export function PredictModal({
|
|||||||
const homeName = match.home_team_zh || match.home_team
|
const homeName = match.home_team_zh || match.home_team
|
||||||
const awayName = match.away_team_zh || match.away_team
|
const awayName = match.away_team_zh || match.away_team
|
||||||
|
|
||||||
|
// ── 无障碍与滚动锁定 ──
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||||
|
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
|
||||||
|
panelRef.current?.focus()
|
||||||
|
|
||||||
const h = (e: KeyboardEvent) => {
|
const h = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose()
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
// 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面
|
||||||
|
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
)
|
||||||
|
if (!focusables || focusables.length === 0) return
|
||||||
|
const first = focusables[0]
|
||||||
|
const last = focusables[focusables.length - 1]
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', h)
|
document.addEventListener('keydown', h)
|
||||||
return () => document.removeEventListener('keydown', h)
|
// 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', h)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
// 关闭后把焦点还给触发元素
|
||||||
|
previouslyFocused.current?.focus()
|
||||||
|
}
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||||
@@ -203,7 +239,11 @@ export function PredictModal({
|
|||||||
if (e.target === e.currentTarget) onClose()
|
if (e.target === e.currentTarget) onClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 shadow-2xl">
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
|
||||||
|
>
|
||||||
{/* 弹窗报头 */}
|
{/* 弹窗报头 */}
|
||||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||||
|
|||||||
@@ -24,12 +24,39 @@ export default {
|
|||||||
800: '#282420',
|
800: '#282420',
|
||||||
900: '#17140F',
|
900: '#17140F',
|
||||||
},
|
},
|
||||||
// 印报红:全站唯一强调色,克制使用
|
// 印报红:全站唯一强调色,克制使用
|
||||||
press: {
|
press: {
|
||||||
DEFAULT: '#9E1B1B',
|
DEFAULT: '#9E1B1B',
|
||||||
dark: '#7C1414',
|
dark: '#7C1414',
|
||||||
wash: '#F7E9E4',
|
wash: '#F7E9E4',
|
||||||
},
|
},
|
||||||
|
// ── 语义状态色:成功/警告/负面 ──
|
||||||
|
// 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA),
|
||||||
|
// 点/条级(500) ≥ 3:1;色相降饱和以贴近墨色印刷感,不使用 tailwind 原色。
|
||||||
|
ok: {
|
||||||
|
50: '#EAF3ED',
|
||||||
|
100: '#D5E8DC',
|
||||||
|
300: '#A3C9B1',
|
||||||
|
500: '#43925F',
|
||||||
|
600: '#2E7A4C',
|
||||||
|
700: '#1F6B45',
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
50: '#FBF3E4',
|
||||||
|
100: '#F5E5C8',
|
||||||
|
300: '#E2C48E',
|
||||||
|
500: '#D97706',
|
||||||
|
600: '#A15C0B',
|
||||||
|
700: '#8F5109',
|
||||||
|
},
|
||||||
|
bad: {
|
||||||
|
50: '#FAEDED',
|
||||||
|
100: '#F6E3E3',
|
||||||
|
300: '#E4AFAF',
|
||||||
|
500: '#C24A4A',
|
||||||
|
600: '#A83B3B',
|
||||||
|
700: '#8F3030',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
// 毛体草书(国内 CDN)+ 粗楷体回退
|
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||||
|
|||||||
Reference in New Issue
Block a user