fix+integrate: 5 新组件接入 Dashboard/Logs 页面
Dashboard: - 数据概览用 InsightGrid + InsightCard 展示预测/比赛/统计指标 - 数据源/已入库联赛卡片改用自定义布局(去除旧 Card 依赖) - 加载态用 LoaderGrid Logs: - 日志列表改用 FilterTable(列排序 + 级别/关键字筛选) - 加载态用 LoaderGrid row 模式 - 级别/Badge 筛选保留原有交互 FilterTable 泛型约束放宽为 Record<string, any>(兼容 interface)。
This commit is contained in:
@@ -25,3 +25,7 @@ export { Spinner } from '../components/ui'
|
|||||||
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
|
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
|
||||||
*/
|
*/
|
||||||
export { Skeleton as SkeletonBlock } from '../components/ui'
|
export { Skeleton as SkeletonBlock } from '../components/ui'
|
||||||
|
|
||||||
|
// ── P1 新增: 数据展示组件(灵感来自 beautifului.dev) ────────────────
|
||||||
|
export { EventList, FilterTable, InsightCard, InsightGrid, LoaderGrid, SearchList, TrendingArrow } from '../components/ui'
|
||||||
|
export type { EventItem, FilterColumn, InsightCardProps } from '../components/ui'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Link } from 'react-router-dom'
|
|||||||
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal'
|
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal'
|
||||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
||||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types'
|
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types'
|
||||||
import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
|
import { InsightGrid, InsightCard, LoaderGrid } from '../components'
|
||||||
|
|
||||||
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
||||||
const STEPS = [
|
const STEPS = [
|
||||||
@@ -91,7 +91,7 @@ export default function Dashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* ── 待办行:只有真有待办才出现 ── */}
|
{/* ── 待办行:只有真有待办才出现 ── */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonBlock className="h-14 w-full" />
|
<div className="h-14 w-full animate-pulse bg-paper-100" role="presentation" />
|
||||||
) : todos.length > 0 ? (
|
) : todos.length > 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
{todos.map(t => (
|
{todos.map(t => (
|
||||||
@@ -138,92 +138,65 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
{/* ── 数据源:单源一行即足,不装成列表 ── */}
|
||||||
<Card>
|
<div className="border border-ink-200 bg-paper-50">
|
||||||
<CardHeader
|
<div className="flex items-center justify-between border-b border-ink-200 px-4 py-2 sm:px-5">
|
||||||
title="数据源"
|
<h2 className="text-sm font-medium text-ink-700">数据源</h2>
|
||||||
description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
|
<span className="text-xs text-ink-400">bzzoiro</span>
|
||||||
/>
|
</div>
|
||||||
<CardBody className="px-0">
|
<div className="px-4 py-3 sm:px-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
|
<LoaderGrid mode="row" count={1} cols={2} />
|
||||||
) : (
|
) : (() => {
|
||||||
(() => {
|
const st = bzzoiro
|
||||||
const st = bzzoiro
|
const hasData = st && st.recent_count > 0
|
||||||
const hasData = st && st.recent_count > 0
|
const keyOk = st?.key_configured !== false
|
||||||
const keyOk = st?.key_configured !== false
|
return (
|
||||||
return (
|
<div className="flex items-center justify-between text-xs">
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
|
<span className="text-ink-700">Bzzoiro</span>
|
||||||
<span className="text-xs font-medium text-ink-700">Bzzoiro</span>
|
<span className="flex items-center gap-3 text-2xs">
|
||||||
<span className="flex items-center gap-3 text-2xs">
|
{hasData ? (
|
||||||
{hasData ? (
|
<>
|
||||||
<>
|
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} 条</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>
|
||||||
<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 ? (
|
||||||
) : keyOk ? (
|
<span className="text-ink-400">无数据</span>
|
||||||
<span className="text-ink-400">无数据</span>
|
) : (
|
||||||
) : (
|
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
||||||
<Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2">未配置 Key,去设置</Link>
|
)}
|
||||||
)}
|
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
|
||||||
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
})()}
|
||||||
})()
|
</div>
|
||||||
)}
|
</div>
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
|
{/* ── 数据概览:InsightCard 指标卡 ── */}
|
||||||
<Card>
|
{stats ? (
|
||||||
<CardHeader title="数据概览" description="预测调用量与各表数据量" />
|
<div className="space-y-4">
|
||||||
<CardBody>
|
<InsightGrid>
|
||||||
{stats ? (
|
<InsightCard label="预测 · 近 24 小时" value={stats.predictions.last_24h} status="info" />
|
||||||
<div className="space-y-4">
|
<InsightCard label="预测 · 近 7 天" value={stats.predictions.last_7d} status="info" />
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<InsightCard label="预测 · 累计" value={stats.predictions.total} status="positive" />
|
||||||
<div>
|
</InsightGrid>
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
|
<InsightGrid>
|
||||||
<div className="mt-1 text-2xs text-ink-400">预测 · 近 24 小时</div>
|
<InsightCard label="比赛总数" value={stats.matches?.total ?? 0} subtitle={`已完赛 ${stats.matches?.finished ?? 0}`} status="info" />
|
||||||
</div>
|
<InsightCard label="统计行" value={stats.stats?.total ?? 0} status="info" />
|
||||||
<div>
|
<InsightCard label="积分榜" value={stats.standings?.total ?? 0} status="info" />
|
||||||
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
|
</InsightGrid>
|
||||||
<div className="mt-1 text-2xs text-ink-400">预测 · 近 7 天</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div>
|
<LoaderGrid mode="card" count={6} />
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">比赛总数</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.finished ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">已完赛</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.stats?.total ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">统计行</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.standings?.total ?? 0}</div>
|
|
||||||
<div className="mt-0.5 text-2xs text-ink-400">积分榜</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center py-4"><SkeletonBlock className="h-12 w-64" /></div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ── 已入库联赛 ── */}
|
{/* ── 已入库联赛 ── */}
|
||||||
{dash && dash.leagues.length > 0 && (
|
{dash && dash.leagues.length > 0 && (
|
||||||
<Card>
|
<div className="border border-ink-200 bg-paper-50">
|
||||||
<CardHeader title="已入库联赛" />
|
<div className="border-b border-ink-200 px-4 py-2 sm:px-5">
|
||||||
<CardBody>
|
<h2 className="text-sm font-medium text-ink-700">已入库联赛</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 sm:px-5">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{dash.leagues.map(l => (
|
{dash.leagues.map(l => (
|
||||||
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
|
<span key={l.code} className="border border-ink-200 px-2.5 py-1 text-xs text-ink-700">
|
||||||
@@ -232,8 +205,8 @@ export default function Dashboard() {
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { fetchLogs } from '../../api/dal'
|
import { fetchLogs } from '../../api/dal'
|
||||||
import type { LogEntry } from '../../api/types'
|
import type { LogEntry } from '../../api/types'
|
||||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
import { Badge, SectionHeader, Alert, Card, CardHeader, CardBody, Spinner, FilterTable, type FilterColumn, LoaderGrid } from '../components'
|
||||||
import { Button, Input } from '../../components/ui'
|
import { Button, Input } from '../../components/ui'
|
||||||
|
|
||||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
||||||
@@ -23,6 +23,52 @@ const LEVEL_BADGE: Record<string, { status: 'success' | 'info' | 'warning' | 'er
|
|||||||
CRITICAL: { status: 'error', text: 'FATAL' },
|
CRITICAL: { status: 'error', text: 'FATAL' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_COLUMNS: FilterColumn<LogEntry>[] = [
|
||||||
|
{
|
||||||
|
key: 'ts',
|
||||||
|
label: '时间',
|
||||||
|
width: '160px',
|
||||||
|
sortable: true,
|
||||||
|
render: r => <span className="tabular-nums text-ink-400">{fmtTs(r.ts)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'level',
|
||||||
|
label: '级别',
|
||||||
|
width: '80px',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
filterType: 'select',
|
||||||
|
filterOptions: [
|
||||||
|
{ value: 'DEBUG', label: 'DEBUG' },
|
||||||
|
{ value: 'INFO', label: 'INFO' },
|
||||||
|
{ value: 'WARNING', label: 'WARN' },
|
||||||
|
{ value: 'ERROR', label: 'ERROR' },
|
||||||
|
],
|
||||||
|
render: r => {
|
||||||
|
const badge = LEVEL_BADGE[r.level] ?? { status: 'info' as const, text: r.level }
|
||||||
|
return <Badge status={badge.status}>{badge.text}</Badge>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logger',
|
||||||
|
label: 'Logger',
|
||||||
|
width: '160px',
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
render: r => (
|
||||||
|
<span className="truncate font-mono text-ink-400" title={r.logger}>
|
||||||
|
{r.logger}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'message',
|
||||||
|
label: '消息',
|
||||||
|
filterable: true,
|
||||||
|
render: r => <span className="break-all font-mono leading-relaxed text-ink-800">{r.message}</span>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
function fmtTs(ts: number): string {
|
function fmtTs(ts: number): string {
|
||||||
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||||
}
|
}
|
||||||
@@ -53,26 +99,7 @@ export default function LogsPage() {
|
|||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
|
||||||
const userScrolledDown = useRef(false)
|
|
||||||
|
|
||||||
// 检测用户是否向下滚动回看历史(旧日志在下方)
|
|
||||||
const handleScroll = () => {
|
|
||||||
const el = scrollRef.current
|
|
||||||
if (!el) return
|
|
||||||
const atTop = el.scrollTop < 50
|
|
||||||
userScrolledDown.current = !atTop
|
|
||||||
}
|
|
||||||
|
|
||||||
// 列表最新在最上面(后端倒序返回);打开页面/自动刷新时定位到顶部=最新。
|
|
||||||
// 用户向下滚动回看历史时暂停定位,拉回顶部即恢复。
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoRefresh && !userScrolledDown.current && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = 0
|
|
||||||
}
|
|
||||||
}, [entries, autoRefresh])
|
|
||||||
|
|
||||||
// 自动刷新
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current) clearInterval(timerRef.current)
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
@@ -137,36 +164,18 @@ export default function LogsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 日志列表 */}
|
{/* 日志表格:FilterTable(列排序 + 筛选) */}
|
||||||
{loading && entries.length === 0 ? (
|
{loading && entries.length === 0 ? (
|
||||||
<div className="space-y-2 px-4 py-3 sm:px-5">
|
<LoaderGrid mode="row" count={6} cols={4} />
|
||||||
{[1, 2, 3, 4, 5].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
|
|
||||||
</div>
|
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
<p className="py-10 text-center text-xs text-ink-400">暂无匹配的日志</p>
|
||||||
) : (
|
) : (
|
||||||
<div ref={scrollRef} onScroll={handleScroll} className="max-h-[60vh] overflow-y-auto">
|
<FilterTable
|
||||||
{entries.map((e, i) => {
|
columns={LOG_COLUMNS}
|
||||||
const badge = LEVEL_BADGE[e.level] ?? { status: 'info' as const, text: e.level }
|
data={entries}
|
||||||
return (
|
rowKey={e => `${e.ts}-${e.logger}-${e.message.slice(0, 20)}`}
|
||||||
<div
|
emptyText="暂无匹配的日志"
|
||||||
key={`${e.ts}-${i}`}
|
/>
|
||||||
className="flex flex-col gap-0.5 border-b border-ink-200 px-4 py-2 last:border-b-0 sm:flex-row sm:items-baseline sm:gap-3 sm:px-5"
|
|
||||||
>
|
|
||||||
<span className="w-40 flex-shrink-0 text-2xs tabular-nums text-ink-400">{fmtTs(e.ts)}</span>
|
|
||||||
<span className="w-14 flex-shrink-0">
|
|
||||||
<Badge status={badge.status}>{badge.text}</Badge>
|
|
||||||
</span>
|
|
||||||
<span className="w-40 flex-shrink-0 truncate font-mono text-2xs text-ink-400" title={e.logger}>
|
|
||||||
{e.logger}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1 break-all font-mono text-2xs leading-relaxed text-ink-800">
|
|
||||||
{e.message}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface FilterColumn<T> {
|
|||||||
|
|
||||||
type SortDir = 'asc' | 'desc' | null
|
type SortDir = 'asc' | 'desc' | null
|
||||||
|
|
||||||
export function FilterTable<T extends Record<string, unknown>>({
|
export function FilterTable<T extends Record<string, any>>({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
rowKey,
|
rowKey,
|
||||||
|
|||||||
Reference in New Issue
Block a user