211 lines
7.5 KiB
TypeScript
211 lines
7.5 KiB
TypeScript
/**
|
|
* Admin 后台 - 数据采集页面(报刊风)
|
|
*
|
|
* 响应式布局: 移动端单列,桌面端双列
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { triggerCollection, fetchLeagues } from '../dal'
|
|
import type { CollectionRequest, League } from '../types'
|
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
|
|
|
const SOURCES = [
|
|
{ value: 'bzzoiro', label: 'Bzzoiro', desc: '历史赛程与比分' },
|
|
{ value: 'understat', label: 'Understat', desc: 'xG 进阶数据' },
|
|
{ value: 'injuries', label: 'Injuries', desc: '球员伤停' },
|
|
] as const
|
|
|
|
/** 把采集接口返回摘要成一两行可读文字 */
|
|
function summarizeResult(res: any, source: string): { title: string; detail: string } {
|
|
if (res && typeof res === 'object') {
|
|
if (source === 'bzzoiro' && ('total_inserted' in res || 'total_updated' in res)) {
|
|
return {
|
|
title: `采集完成:新增 ${res.total_inserted ?? 0} 条,更新 ${res.total_updated ?? 0} 条`,
|
|
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
|
}
|
|
}
|
|
if ('count' in res || 'updated' in res) {
|
|
const parts = [
|
|
`新增 ${res.count ?? 0}`,
|
|
`更新 ${res.updated ?? 0}`,
|
|
`跳过 ${res.skipped ?? 0}`,
|
|
`未匹配 ${res.unmatched ?? 0}`,
|
|
]
|
|
return {
|
|
title: `采集完成:${parts.join(' / ')}`,
|
|
detail: Array.isArray(res.errors) && res.errors.length > 0 ? res.errors.join('\n') : '',
|
|
}
|
|
}
|
|
}
|
|
return { title: '采集完成', detail: JSON.stringify(res)?.slice(0, 300) ?? '' }
|
|
}
|
|
|
|
export default function CollectionPage() {
|
|
const [leagues, setLeagues] = useState<League[]>([])
|
|
const [source, setSource] = useState<string>('bzzoiro')
|
|
const [leagueCode, setLeagueCode] = useState('')
|
|
const [dateFrom, setDateFrom] = useState('')
|
|
const [dateTo, setDateTo] = useState('')
|
|
const [season, setSeason] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [result, setResult] = useState<{ title: string; detail: string } | null>(null)
|
|
|
|
const loadLeagues = useCallback(async () => {
|
|
const lg = await fetchLeagues()
|
|
setLeagues(lg)
|
|
}, [])
|
|
|
|
useEffect(() => { loadLeagues() }, [loadLeagues])
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setResult(null)
|
|
setLoading(true)
|
|
|
|
try {
|
|
const body: CollectionRequest = {
|
|
source: source as CollectionRequest['source'],
|
|
leagues: leagueCode ? [leagueCode] : undefined,
|
|
league: leagueCode || undefined,
|
|
season: season || undefined,
|
|
date_from: dateFrom || undefined,
|
|
date_to: dateTo || undefined,
|
|
}
|
|
const res = await triggerCollection(body)
|
|
setResult(summarizeResult(res, source))
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : '采集触发失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="数据采集"
|
|
description="触发数据源采集,支持联赛筛选和日期范围。采集为同步执行,大范围日期耗时较长。"
|
|
/>
|
|
|
|
<div className="grid gap-6 lg:grid-cols-2">
|
|
{/* 采集表单 */}
|
|
<Card>
|
|
<CardHeader title="新建采集任务" />
|
|
<CardBody>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* 数据源选择 */}
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">数据源</label>
|
|
<select
|
|
value={source}
|
|
onChange={e => setSource(e.target.value)}
|
|
className="field w-full"
|
|
>
|
|
{SOURCES.map(s => (
|
|
<option key={s.value} value={s.value}>
|
|
{s.label} — {s.desc}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* 联赛选择 */}
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
|
<select
|
|
value={leagueCode}
|
|
onChange={e => setLeagueCode(e.target.value)}
|
|
className="field w-full"
|
|
>
|
|
<option value="">全部联赛</option>
|
|
{leagues.map(l => (
|
|
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Understat 专用: 赛季 */}
|
|
{source === 'understat' && (
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">赛季(起始年)</label>
|
|
<input
|
|
type="number"
|
|
value={season}
|
|
onChange={e => setSeason(e.target.value)}
|
|
placeholder="2025"
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 日期范围 */}
|
|
{source !== 'injuries' && (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
|
<input
|
|
type="date"
|
|
value={dateFrom}
|
|
onChange={e => setDateFrom(e.target.value)}
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
|
<input
|
|
type="date"
|
|
value={dateTo}
|
|
onChange={e => setDateTo(e.target.value)}
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 消息提示 */}
|
|
{error && <Alert kind="error" title="采集失败" message={error} onClose={() => setError(null)} />}
|
|
{result && (
|
|
<Alert
|
|
kind="ok"
|
|
title={result.title}
|
|
message={result.detail || undefined}
|
|
onClose={() => setResult(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* 提交按钮 */}
|
|
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
|
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
|
</button>
|
|
</form>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 数据源说明 */}
|
|
<Card>
|
|
<CardHeader title="数据源说明" />
|
|
<CardBody>
|
|
<div className="space-y-3">
|
|
{SOURCES.map(s => (
|
|
<div key={s.value} className="border-b border-ink-200 px-1 py-3 last:border-b-0">
|
|
<div className="flex items-center gap-3">
|
|
<Badge status="info">{s.label}</Badge>
|
|
<p className="text-xs text-ink-600">{s.desc}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<p className="mt-4 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
|
|
若后端配置了 ADMIN_API_KEY,采集接口需要管理员密钥。
|
|
遇到 401 请到「系统配置」页填写密钥。
|
|
</p>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|