@@ -65,6 +65,96 @@ const LEAGUES = [
{ code : 'F1' , name : '法甲' } ,
]
const STATUS_META : Record < string , { label : string ; cls : string } > = {
finished : { label : '已完赛' , cls : 'bg-ink-100 text-ink-600' } ,
scheduled : { label : '未开赛' , cls : 'bg-brand-50 text-brand-700' } ,
live : { label : '进行中' , cls : 'bg-emerald-50 text-emerald-700' } ,
}
/** 1x2 → 中文标签 */
const OUTCOME_LABEL : Record < string , string > = { '1' : '主胜' , X : '平局' , '2' : '客胜' }
/** 队名取首字作视觉标记(替代队徽图片,避免额外资源与 404) */
function initial ( name : string ) : string {
return ( name || '?' ) . trim ( ) . charAt ( 0 )
}
/** 依据队名生成一个稳定的色相,让不同球队有可区分的淡色底 */
function hueOf ( name : string ) : number {
let h = 0
for ( let i = 0 ; i < name . length ; i ++ ) h = ( h * 31 + name . charCodeAt ( i ) ) % 360
return h
}
function TeamMark ( { name , size = 40 } : { name : string ; size? : number } ) {
const h = hueOf ( name )
return (
< span
className = "flex flex-shrink-0 items-center justify-center rounded-lg font-semibold"
style = { {
width : size ,
height : size ,
fontSize : size * 0.4 ,
background : ` hsl( ${ h } 70% 96%) ` ,
color : ` hsl( ${ h } 55% 38%) ` ,
border : ` 1px solid hsl( ${ h } 60% 88%) ` ,
} }
aria-hidden = "true"
>
{ initial ( name ) }
< / span >
)
}
/** 置信度条:把 0~1 的数值可视化,比纯数字更易读 */
function Meter ( { value , tone = 'brand' } : { value : number ; tone ? : 'brand' | 'emerald' | 'amber' } ) {
const pct = Math . max ( 0 , Math . min ( 100 , Math . round ( value * 100 ) ) )
const bar =
tone === 'emerald' ? 'bg-emerald-500' : tone === 'amber' ? 'bg-amber-500' : 'bg-brand-500'
return (
< div className = "h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role = "presentation" >
< div className = { ` h-full rounded-pill ${ bar } transition-[width] duration-500 ` } style = { { width : ` ${ pct } % ` } } / >
< / div >
)
}
/** home_edge(-1~1,正=利主队)的可视化:以中线为原点的双向条 */
function EdgeBar ( { value } : { value : number } ) {
const v = Math . max ( - 1 , Math . min ( 1 , value ) )
const half = Math . abs ( v ) * 50
return (
< div className = "relative h-1.5 w-full overflow-hidden rounded-pill bg-ink-200" role = "presentation" >
< span className = "absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-ink-300" / >
< span
className = { ` absolute top-0 h-full transition-all duration-500 ${ v >= 0 ? 'bg-brand-500' : 'bg-amber-500' } ` }
style = {
v >= 0
? { left : '50%' , width : ` ${ half } % ` }
: { right : '50%' , width : ` ${ half } % ` }
}
/ >
< / div >
)
}
/** 骨架屏行,替代 emoji spinner */
function SkeletonRows ( { n = 4 } : { n? : number } ) {
return (
< >
{ Array . from ( { length : n } ) . map ( ( _ , i ) = > (
< div key = { i } className = "flex items-center gap-4 rounded-card border border-ink-200 bg-white p-4" >
< div className = "skeleton h-10 w-10 rounded-lg" / >
< div className = "flex-1 space-y-2" >
< div className = "skeleton h-3.5 w-40" / >
< div className = "skeleton h-3 w-24" / >
< / div >
< div className = "skeleton h-8 w-20 rounded-lg" / >
< / div >
) ) }
< / >
)
}
export default function Matches() {
const [ league , setLeague ] = useState ( 'E0' )
const [ status , setStatus ] = useState ( 'scheduled' )
@@ -74,6 +164,7 @@ export default function Matches() {
const [ loading , setLoading ] = useState ( false )
const [ predictingId , setPredictingId ] = useState < number | null > ( null )
const [ prediction , setPrediction ] = useState < Prediction | null > ( null )
const [ predictionFor , setPredictionFor ] = useState < Match | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const [ mode , setMode ] = useState < 'single' | 'multi' > ( 'multi' )
@@ -106,7 +197,7 @@ export default function Matches() {
} , [ league , status ] )
// 加载下一页(游标分页)。后端已支持 cursor,前端此前未使用,
// 导致 limit=50 之后的数据永远看不到(见审查报告 P2-8) 。
// 导致 limit=50 之后的数据永远看不到。
const loadMore = async ( ) = > {
if ( ! nextCursor || loadingMore ) return
const seq = loadSeq . current // 不做自增:切换筛选会自增,这里只跟随当前序列
@@ -130,16 +221,17 @@ export default function Matches() {
useEffect ( ( ) = > { load ( ) } , [ load ] )
const predict = async ( matchId : number ) = > {
const predict = async ( m : Match ) = > {
const seq = ++ predictSeq . current
setPredictingId ( matchId )
setPredictingId ( m . id )
setError ( null )
setPrediction ( null )
setPredictionFor ( m )
try {
const res = await fetch ( '/api/v1/predict' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON.stringify ( { match_id : matchId , mode } ) ,
body : JSON.stringify ( { match_id : m.id , mode } ) ,
} )
if ( seq !== predictSeq . current ) return
if ( ! res . ok ) {
@@ -162,205 +254,499 @@ export default function Matches() {
return d . toLocaleString ( 'zh-CN' , { month : '2-digit' , day : '2-digit' , hour : '2-digit' , minute : '2-digit' } )
}
const leagueName = LEAGUES . find ( l = > l . code === league ) ? . name ? ? league
return (
< div className = "space-y-4" >
{ /* 筛选 */ }
< div className = "flex gap-3 items-center flex-wrap " >
< select value = { league } onChange = { e = > setLeague ( e . target . value ) }
className = "border rounded px-3 py-1.5 text-sm" >
{ LEAGUES . map ( l = > < option key = { l . code } value = { l . code } > { l . name } < / option > ) }
< / select >
< select value = { status } onChange = { e = > setStatus ( e . target . value ) }
className = "border rounded px-3 py-1.5 text-sm" >
< option value = "scheduled" > 未 开 赛 < / option >
< option value = "finished" > 已 完 赛 < / option >
< option value = "" > 全 部 < / option >
< / select >
< div className = "flex items-center gap-2 text-sm" >
< span className = "text-gray-500 " > 模 式 : < / span >
< button
onClick = { ( ) = > setMode ( 'single' ) }
className = { ` px-2 py-1 rounded text-xs ${ mode === 'single' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600' } ` }
> 单 次 < / button >
< button
onClick = { ( ) = > setMode ( 'multi' ) }
className = { ` px-2 py-1 rounded text-xs ${ mode === 'multi' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600' } ` }
> 多 Agent < / button >
{ /* ── 工具栏 ── */ }
< div className = "card flex flex-wrap items-center gap-3 p-3 " >
< div className = "flex items-center gap-2" >
< label className = "text-2xs font-medium text-ink-500" > 联 赛 < / label >
< select value = { league } onChange = { e = > setLeague ( e . target . value ) } className = "field w-28" >
{ LEAGUES . map ( l = > < option key = { l . code } value = { l . code } > { l . name } < / option > ) }
< / select >
< / div >
< div className = "flex items-center gap-2" >
< label className = "text-2xs font-medium text-ink-500 " > 状 态 < / label >
< select value = { status } onChange = { e = > setStatus ( e . target . value ) } className = "field w-28" >
< option value = "scheduled" > 未 开 赛 < / option >
< option value = "finished " > 已 完 赛 < / option >
< option value = "" > 全 部 < / option >
< / select >
< / div >
< div className = "flex items-center gap-2" >
< label className = "text-2xs font-medium text-ink-500" > 预 测 模 式 < / label >
< div className = "inline-flex rounded-lg bg-ink-100 p-0.5" >
< button
onClick = { ( ) = > setMode ( 'single' ) }
className = { ` segment ${ mode === 'single' ? 'segment-on' : 'segment-off' } ` }
title = "单次调用,快但只有一个模型看全部数据"
> 单 次 < / button >
< button
onClick = { ( ) = > setMode ( 'multi' ) }
className = { ` segment ${ mode === 'multi' ? 'segment-on' : 'segment-off' } ` }
title = "5 个专家并行分析后由终裁汇总,质量更高"
> 多 Agent < / button >
< / div >
< / div >
< div className = "ml-auto flex items-center gap-3" >
< span className = "text-2xs text-ink-500" >
共 < span className = "font-semibold text-ink-700" > { matches . length } < / span > 场
< / span >
< button onClick = { load } disabled = { loading } className = "btn-ghost btn-sm" >
{ loading ? (
< >
< Spinner / > 加 载 中
< / >
) : '刷新' }
< / button >
< / div >
< button onClick = { load } disabled = { loading }
className = "bg-blue-600 text-white text-sm px-4 py-1.5 rounded hover:bg-blue-700 disabled:opacity-50" >
{ loading ? '加载中...' : '刷新' }
< / button >
< span className = "text-sm text-gray-500" > 共 { matches . length } 场 < / span >
< / div >
{ /* ── 错误提示 ── */ }
{ error && (
< div className = "bg-red-50 border border-red-200 text -red-70 0 px-4 py-2 rounded text-sm flex items-center justify-between " >
< span > ❌ { error } < / span >
< button onClick = { ( ) = > setError ( null ) } className = "text-red-500 hover: text-red-7 00 text-xs" > ✕ < / button >
< / div >
) }
{ /* 比赛表 */ }
< div className = "bg-white rounded border overflow-hidden" >
< table className = "w-full text-sm" >
< thead className = "bg-gray-100 text-gray-600" >
< tr >
< th className = "text-left px-4 py-2" > 日 期 < / th >
< th className = "text-left px-4 py-2" > 主 队 < / th >
< th className = "text-left px-4 py-2" > 客 队 < / th >
< th className = "text-center px-4 py-2" > 比 分 < / th >
< th className = "text-center px-4 py-2" > 状 态 < / th >
< th className = "text-center px-4 py-2" > 操 作 < / th >
< / tr >
< / thead >
< tbody >
{ loading && (
< tr > < td colSpan = { 6 } className = "text-center text-gray-400 py-8" >
< span className = "inline-block animate-spin mr-2" > ⏳ < / span > 加 载 中 . . .
< / td > < / tr >
) }
{ ! loading && matches . length === 0 && (
< tr > < td colSpan = { 6 } className = "text-center text-gray-400 py-8" > 暂 无 数 据 , 请 先 采 集 < / td > < / tr >
) }
{ matches . map ( m = > (
< tr key = { m . id } className = "border-t hover:bg-gray-50" >
< td className = "px-4 py-2 text-gray-600" > { fmtDate ( m . match_date ) } < / td >
< td className = "px-4 py-2 font-medium" > { m . home_team_zh || m . home_team } < / td >
< td className = "px-4 py-2 font-medium" > { m . away_team_zh || m . away_team } < / td >
< td className = "px-4 py-2 text-center" >
{ m . home_goals !== null ? ` ${ m . home_goals } - ${ m . away_goals } ` : '-' }
< / td >
< td className = "px-4 py-2 text-center" >
< span className = { ` text-xs px-2 py-0.5 rounded ${
m . match_status === 'finished' ? 'bg-green-100 text-green-700' :
m . match_status === 'scheduled' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100'
} ` } >
{ m . match_status === 'finished' ? '完赛' : m . match_status === 'scheduled' ? '未开赛' : m . match_status }
< / span >
< / td >
< td className = "px-4 py-2 text-center" >
< button onClick = { ( ) = > predict ( m . id ) }
disabled = { predictingId === m . id }
className = "text-blue-600 hover:underline text-xs disabled:opacity-50" >
{ predictingId === m . id ? '预测中...' : 'LLM 预测' }
< / button >
< / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
{ /* 分页: 加载更多 */ }
{ nextCursor && (
< div className = "flex justify-center" >
< button onClick = { loadMore } disabled = { loadingMore }
className = "bg-white border text-gray-700 text-sm px-6 py-2 rounded hover:bg-gray-50 disabled:opacity-50" >
{ loadingMore ? '加载中...' : '加载更多' }
< div className = "flex items-start justify-between gap-3 rounded-card border border-red-200 bg -red-5 0 px-4 py-3 " >
< div className = "flex items-start gap-2.5" >
< svg viewBox = "0 0 20 20" className = "mt-0.5 h-4 w-4 flex-shrink-0 text-red-5 00" fill = "currentColor" aria-hidden = "true" >
< path fillRule = "evenodd" d = "M10 18a8 8 0 100-16 8 8 0 000 16zM9 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm1-4a1 1 0 100 2 1 1 0 000-2z" clipRule = "evenodd" / >
< / svg >
< div >
< p className = "text-sm font-medium text-red-800" > 请 求 失 败 < / p >
< p className = "mt-0.5 text-xs text-red-600" > { error } < / p >
< / div >
< / div >
< button onClick = { ( ) = > setError ( null ) } className = "text-red-400 transition-colors hover:text-red-600" aria-label = "关闭" >
< svg viewBox = "0 0 20 20" className = "h-4 w-4" fill = "currentColor" aria-hidden = "true" >
< path d = "M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" / >
< / svg >
< / button >
< / div >
) }
{ /* 预测中指示 */ }
{ predictingId && (
< div className = "bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded text-sm flex items-center gap-2" >
< span className = "inline-block animate-spin" > ⏳ < / span >
LLM 预 测 中 , 请 稍 候 . . .
{ /* ── 比赛列表 ── */ }
< div className = "space-y-2.5" >
{ loading && < SkeletonRows n = { 4 } / > }
{ ! loading && matches . length === 0 && (
< div className = "card flex flex-col items-center justify-center gap-2 py-16" >
< svg viewBox = "0 0 24 24" className = "h-10 w-10 text-ink-300" fill = "none" stroke = "currentColor" strokeWidth = "1.4" aria-hidden = "true" >
< circle cx = "12" cy = "12" r = "9" / >
< path d = "M12 7v5l3 2" strokeLinecap = "round" / >
< / svg >
< p className = "text-sm font-medium text-ink-600" > 暂 无 比 赛 数 据 < / p >
< p className = "text-xs text-ink-400" > 请 先 通 过 采 集 接 口 导 入 { leagueName } 的 赛 程 < / p >
< / div >
) }
{ ! loading && matches . map ( m = > {
const st = STATUS_META [ m . match_status ] ? ? { label : m.match_status , cls : 'bg-ink-100 text-ink-600' }
const homeName = m . home_team_zh || m . home_team
const awayName = m . away_team_zh || m . away_team
const busy = predictingId === m . id
const active = predictionFor ? . id === m . id
return (
< div
key = { m . id }
className = { ` group card p-4 transition-all duration-200 hover:shadow-raise ${
active ? 'ring-1 ring-brand-300' : ''
} ` }
>
< div className = "flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4" >
{ /* 对阵 */ }
< div className = "flex min-w-0 flex-1 items-center gap-2 sm:gap-3" >
< div className = "flex min-w-0 flex-1 items-center justify-end gap-2 sm:gap-2.5" >
< span className = "truncate text-sm font-medium text-ink-800" > { homeName } < / span >
< TeamMark name = { homeName } size = { 36 } / >
< / div >
< div className = "flex w-14 flex-shrink-0 flex-col items-center sm:w-16" >
{ m . home_goals !== null && m . away_goals !== null ? (
< span className = "text-base font-semibold tabular-nums text-ink-900" >
{ m . home_goals } < span className = "mx-0.5 text-ink-300" > : < / span > { m . away_goals }
< / span >
) : (
< span className = "text-xs font-medium text-ink-300" > VS < / span >
) }
{ m . home_xg !== null && m . away_xg !== null && (
< span className = "mt-0.5 text-2xs tabular-nums text-ink-400" >
xG { m . home_xg . toFixed ( 1 ) } - { m . away_xg . toFixed ( 1 ) }
< / span >
) }
< / div >
< div className = "flex min-w-0 flex-1 items-center gap-2 sm:gap-2.5" >
< TeamMark name = { awayName } size = { 36 } / >
< span className = "truncate text-sm font-medium text-ink-800" > { awayName } < / span >
< / div >
< / div >
< div className = "flex items-center gap-3 sm:flex-shrink-0" >
{ /* 元信息:桌面端独立成列,移动端与按钮同排 */ }
< div className = "flex min-w-0 flex-1 flex-col items-start gap-1 sm:w-40 sm:flex-none" >
< span className = { ` pill ${ st . cls } ` } >
< span className = "h-1.5 w-1.5 rounded-full bg-current opacity-60" / >
{ st . label }
< / span >
< span className = "truncate text-2xs text-ink-400" >
{ m . league_code ? ? '—' } · { fmtDate ( m . match_date ) }
< / span >
< / div >
{ /* 操作:主功能提升为实心按钮 */ }
< button
onClick = { ( ) = > predict ( m ) }
disabled = { busy }
className = "btn-primary btn-sm w-[88px] flex-shrink-0"
title = { ` 以 ${ mode === 'multi' ? '多 Agent' : '单次' } 模式预测这场 ` }
>
{ busy ? (
< >
< Spinner / > 预 测 中
< / >
) : (
< >
< svg viewBox = "0 0 20 20" className = "h-3.5 w-3.5" fill = "currentColor" aria-hidden = "true" >
< path d = "M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" / >
< / svg >
预 测
< / >
) }
< / button >
< / div >
< / div >
< / div >
)
} ) }
{ ! loading && nextCursor && (
< div className = "flex justify-center pt-2" >
< button onClick = { loadMore } disabled = { loadingMore } className = "btn-ghost" >
{ loadingMore ? (
< >
< Spinner / > 加 载 中
< / >
) : '加载更多' }
< / button >
< / div >
) }
< / div >
{ /* ── 预测中:占位卡片(替代 emoji 提示条) ── */ }
{ predictingId && ! prediction && (
< div className = "card animate-fade-up overflow-hidden" >
< div className = "flex items-center gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3" >
< Spinner className = "text-brand-600" / >
< span className = "text-sm font-medium text-ink-700" > 正 在 生 成 预 测 < / span >
< span className = "text-xs text-ink-400" >
{ mode === 'multi' ? '· 5 个专家并行分析后由终裁汇总,约需 20-60 秒' : '· 单次调用,约需 5-15 秒' }
< / span >
< / div >
< div className = "space-y-4 p-5" >
< div className = "flex items-center justify-center gap-6" >
< div className = "skeleton h-3.5 w-24" / >
< div className = "skeleton h-10 w-20 rounded-lg" / >
< div className = "skeleton h-3.5 w-24" / >
< / div >
< div className = "skeleton h-2 w-full rounded-pill" / >
< div className = "grid grid-cols-1 gap-3 sm:grid-cols-2" >
< div className = "skeleton h-20 rounded-card" / >
< div className = "skeleton h-20 rounded-card" / >
< / div >
< / div >
< / div >
) }
{ /* 预测结果 */ }
{ prediction && (
< div className = "bg-white rounded border p-5 space-y-3" >
< h3 className = "font-bold text-lg" > 🤖 LLM 预 测 结 果 < / h3 >
< div className = "grid grid-cols-2 md:grid-cols-4 gap-4 text-sm" >
< div className = "bg-blue-50 rounded p-3" >
< div className = "text-gray-500 text-xs" > 主 进 球 < / div >
< div className = "text-xl font-bold" > { prediction . pred_home_goals ? ? '-' } < / div >
< / div >
< div className = "bg-blue-50 rounded p-3" >
< div className = "text-gray-500 text-xs" > 客 进 球 < / div >
< div className = "text-xl font-bold" > { prediction . pred_away_goals ? ? '-' } < / div >
< / div >
< div className = "bg-amber-50 rounded p-3" >
< div className = "text-gray-500 text-xs" > 胜 平 负 < / div >
< div className = "text-xl font-bold" > { prediction . pred_1x2 ? ? '-' } < / div >
< / div >
< div className = "bg-green-50 rounded p-3" >
< div className = "text-gray-500 text-xs" > 置 信 度 < / div >
< div className = "text-xl font-bold" >
{ prediction . subjective_confidence !== null ? ` ${ ( prediction . subjective_confidence * 100 ) . toFixed ( 0 ) } % ` : '-' }
< / div >
< / div >
< / div >
< div className = "text-xs text-gray-400" >
{ prediction . provider } / { prediction . model } · 耗 时 { prediction . latency_ms } ms
{ prediction . mode === 'multi' && ' · 多 Agent 模式' }
< / div >
{ /* ── 预测结果 ── */ }
{ prediction && predictionFor && (
< PredictionPanel
prediction = { prediction }
match = { predictionFor }
mode = { mode }
/ >
) }
< / div >
)
}
{ /* 各专家 agent 报告 */ }
{ prediction . agent_outputs && prediction . agent_outputs . length > 0 && (
< div className = "space-y-2" >
< div className = "text-sm font-medium text-gray-700" > 专 家 Agent 报 告 < / div >
{ prediction . agent_outputs . map ( ( r ) = > (
< details key = { r . agent } className = "bg-white border rounded" >
< summary className = "cursor-pointer px-3 py-2 text-sm flex items-center justify-between" >
< span className = "font-medium" >
{ AGENT_LABELS [ r . agent ] || r . agent }
{ r . status !== 'ok' && (
< span className = { ` ml-2 text-xs px-1.5 py-0.5 rounded ${
r . status === 'no_data' ? 'bg-gray-100 text-gray-500' : 'bg-red-100 text-red-600'
} ` } >
{ r . status === 'no_data' ? '无数据' : '失败' }
< / span >
) }
< / span >
< span className = "flex gap-3 text-xs text-gray-500" >
{ r . home_edge !== null && (
< span className = { r . home_edge > 0 ? 'text-blue-600' : r . home_edge < 0 ? 'text-amber-600' : '' } >
主 队 优 势 { r . home_edge > 0 ? '+' : '' } { r . home_edge . toFixed ( 2 ) }
< / span >
) }
{ r . subjective_confidence !== null && < span > 信 心 { ( r . subjective_confidence * 100 ) . toFixed ( 0 ) } % < / span > }
{ r . probable_score && < span > 比 分 { r . probable_score } < / span > }
< / span >
< / summary >
< div className = "px-3 pb-3 pt-1 space-y-2 text-sm" >
{ r . analysis && < p className = "text-gray-700" > { r . analysis } < / p > }
{ r . key_evidence . length > 0 && (
< ul className = "text-xs text-gray-500 list-disc pl-4" >
{ r . key_evidence . map ( ( e , i ) = > < li key = { i } > { e } < / li > ) }
< / ul >
) }
{ r . exp_home_goals !== null && r . exp_away_goals !== null && (
< div className = "text-xs text-gray-500" >
进 球 期 望 : { r . exp_home_goals . toFixed ( 1 ) } - { r . exp_away_goals . toFixed ( 1 ) }
< / div >
) }
< div className = "text-xs text-gray-400" >
数 据 充 分 度 { r . data_sufficiency } · { r . model } · { r . latency_ms } ms
< / div >
< / div >
< / details >
) ) }
< / div >
) }
function Spinner ( { className = '' } : { className? : string } ) {
// 纯 SVG 转圈,替代 emoji ⏳(各平台渲染不一致)
return (
< svg
viewBox = "0 0 20 20"
className = { ` h-3.5 w-3.5 animate-spin ${ className } ` }
fill = "none"
aria-hidden = "true"
>
< circle cx = "10" cy = "10" r = "7.5" stroke = "currentColor" strokeWidth = "2" strokeOpacity = "0.25" / >
< path d = "M17.5 10A7.5 7.5 0 0010 2.5" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" / >
< / svg >
)
}
{ prediction . reasoning && (
< div className = "bg-gray-50 rounded p-3" >
< div className = "text-xs text-gray-500 mb-1" > 推 理 过 程 < / div >
< div className = "text-sm whitespace-pre-wrap" > { prediction . reasoning } < / div >
< / div >
) }
< details className = "text-xs" >
< summary className = "cursor-pointer text-gray-500 hover:text-gray-700" > 查 看 完 整 上 下 文 < / summary >
< pre className = "mt-2 bg-gray-900 text-green-300 p-3 rounded overflow-x-auto text-xs" >
{ prediction . context }
< / pre >
< / details >
/** 胜平负概率条:LLM 只给主观置信度,这里把它按 1x2 结果做成单条高亮,
* 并显式标注「主观」避免用户误当概率 */
function OutcomeCard ( {
pick ,
confidence ,
} : {
pick : string | null
confidence : number | null
} ) {
const label = pick ? OUTCOME_LABEL [ pick ] ? ? pick : '—'
const tone = pick === '1' ? 'brand' : pick === 'X' ? 'amber' : 'emerald'
const color =
tone === 'brand' ? 'text-brand-700' : tone === 'amber' ? 'text-amber-700' : 'text-emerald-700'
const bg = tone === 'brand' ? 'bg-brand-50' : tone === 'amber' ? 'bg-amber-50' : 'bg-emerald-50'
const border =
tone === 'brand' ? 'border-brand-100' : tone === 'amber' ? 'border-amber-100' : 'border-emerald-100'
return (
< div className = { ` rounded-card border ${ border } ${ bg } p-4 ` } >
< p className = "text-2xs font-medium text-ink-500" > 胜 平 负 < / p >
< p className = { ` mt-1 text-2xl font-semibold ${ color } ` } > { label } < / p >
{ confidence !== null && (
< div className = "mt-2.5 space-y-1.5" >
< Meter value = { confidence } tone = { tone === 'brand' ? 'brand' : tone === 'amber' ? 'amber' : 'emerald' } / >
< p className = "text-2xs text-ink-400" > 主 观 置 信 度 { Math . round ( confidence * 100 ) } % < / p >
< / div >
) }
< / div >
)
}
function PredictionPanel ( {
prediction ,
match ,
mode ,
} : {
prediction : Prediction
match : Match
mode : 'single' | 'multi'
} ) {
const homeName = match . home_team_zh || match . home_team
const awayName = match . away_team_zh || match . away_team
const okReports = ( prediction . agent_outputs ? ? [ ] ) . filter ( r = > r . status === 'ok' )
return (
< div className = "card animate-fade-up overflow-hidden" >
{ /* 面板头 */ }
< div className = "flex flex-wrap items-center justify-between gap-2 border-b border-ink-200 bg-ink-50 px-5 py-3" >
< div className = "flex items-center gap-2" >
< span className = "flex h-6 w-6 items-center justify-center rounded-md bg-brand-600" >
< svg viewBox = "0 0 20 20" className = "h-3.5 w-3.5 text-white" fill = "currentColor" aria-hidden = "true" >
< path d = "M10 2l1.9 5.1L17 9l-5.1 1.9L10 16l-1.9-5.1L3 9l5.1-1.9L10 2z" / >
< / svg >
< / span >
< h3 className = "text-sm font-semibold text-ink-800" > LLM 预 测 结 果 < / h3 >
{ prediction . mode === 'multi' && (
< span className = "pill bg-brand-50 text-brand-700" >
多 Agent · { okReports . length } / { prediction . agent_outputs ? . length ? ? 0 } 专 家 有 效
< / span >
) }
< / div >
< span className = "text-2xs text-ink-400" >
{ prediction . provider } / { prediction . model }
{ prediction . latency_ms !== null && ` · ${ ( prediction . latency_ms / 1000 ) . toFixed ( 1 ) } s ` }
< / span >
< / div >
< div className = "space-y-5 p-5" >
{ /* ── 对阵 + 预测比分(核心) ── */ }
< div className = "flex items-center justify-center gap-4 sm:gap-8" >
< div className = "flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row sm:justify-end" >
< span className = "order-2 truncate text-sm font-medium text-ink-800 sm:order-1 sm:text-right" > { homeName } < / span >
< span className = "order-1 sm:order-2" > < TeamMark name = { homeName } size = { 44 } / > < / span >
< / div >
< div className = "flex flex-shrink-0 flex-col items-center" >
< div className = "flex items-center gap-1.5" >
< span className = "rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900" >
{ prediction . pred_home_goals ? ? '-' }
< / span >
< span className = "text-lg text-ink-300" > : < / span >
< span className = "rounded-lg bg-ink-100 px-3 py-1.5 text-2xl font-semibold tabular-nums text-ink-900" >
{ prediction . pred_away_goals ? ? '-' }
< / span >
< / div >
< span className = "mt-1.5 text-2xs text-ink-400" > 预 测 比 分 < / span >
< / div >
< div className = "flex min-w-0 flex-1 flex-col items-center gap-2 sm:flex-row" >
< span className = "order-1" > < TeamMark name = { awayName } size = { 44 } / > < / span >
< span className = "order-2 truncate text-sm font-medium text-ink-800 sm:text-left" > { awayName } < / span >
< / div >
< / div >
{ /* ── 指标卡 ── */ }
< div className = "grid grid-cols-1 gap-3 sm:grid-cols-3" >
< OutcomeCard pick = { prediction . pred_1x2 } confidence = { prediction . subjective_confidence } / >
< div className = "rounded-card border border-ink-200 p-4" >
< p className = "text-2xs font-medium text-ink-500" > 预 测 模 式 < / p >
< p className = "mt-1 text-2xl font-semibold text-ink-800" > { mode === 'multi' ? '多 Agent' : '单次' } < / p >
< p className = "mt-2.5 text-2xs text-ink-400" >
prompt { prediction . prompt_version ? ? '—' }
< / p >
< / div >
< div className = "rounded-card border border-ink-200 p-4" >
< p className = "text-2xs font-medium text-ink-500" > 模 型 耗 时 < / p >
< p className = "mt-1 text-2xl font-semibold tabular-nums text-ink-800" >
{ prediction . latency_ms !== null ? ( prediction . latency_ms / 1000 ) . toFixed ( 1 ) : '—' }
< span className = "ml-0.5 text-sm font-normal text-ink-400" > s < / span >
< / p >
< p className = "mt-2.5 text-2xs text-ink-400" > 含 专 家 并 行 + 终 裁 汇 总 < / p >
< / div >
< / div >
{ /* ── 专家报告 ── */ }
{ prediction . agent_outputs && prediction . agent_outputs . length > 0 && (
< div >
< div className = "mb-2.5 flex items-center gap-2" >
< h4 className = "text-xs font-semibold text-ink-700" > 专 家 Agent 报 告 < / h4 >
{ prediction . agent_weights && (
< span className = "text-2xs text-ink-400" >
终 裁 权 重 :
{ Object . entries ( prediction . agent_weights )
. sort ( ( a , b ) = > b [ 1 ] - a [ 1 ] )
. map ( ( [ k , v ] ) = > ` ${ AGENT_LABELS [ k ] ? ? k } ${ Math . round ( v * 100 ) } % ` )
. join ( ' · ' ) }
< / span >
) }
< / div >
< div className = "grid grid-cols-1 gap-2.5 lg:grid-cols-2" >
{ prediction . agent_outputs . map ( r = > (
< AgentCard key = { r . agent } report = { r } / >
) ) }
< / div >
< / div >
) }
{ /* ── 推理过程 ── */ }
{ prediction . reasoning && (
< div className = "rounded-card border border-ink-200 bg-ink-50 p-4" >
< p className = "mb-2 text-2xs font-semibold uppercase tracking-wide text-ink-500" > 终 裁 推 理 < / p >
< p className = "whitespace-pre-wrap text-sm leading-relaxed text-ink-700" > { prediction . reasoning } < / p >
< / div >
) }
{ /* ── 原始上下文 ── */ }
< details className = "group" >
< summary className = "flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-ink-500 transition-colors hover:text-ink-700" >
< svg viewBox = "0 0 20 20" className = "h-3.5 w-3.5 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 >
查 看 喂 给 模 型 的 完 整 数 据 切 片
< / summary >
< pre className = "mt-2 max-h-80 overflow-auto rounded-card border border-ink-700 bg-ink-900 p-4 font-mono text-2xs leading-relaxed text-ink-300" >
{ prediction . context }
< / pre >
< / details >
< / div >
< / div >
)
}
const STATUS_BADGE : Record < string , { label : string ; cls : string } > = {
ok : { label : '正常' , cls : 'bg-emerald-50 text-emerald-700 border-emerald-100' } ,
no_data : { label : '无数据' , cls : 'bg-ink-100 text-ink-500 border-ink-200' } ,
error : { label : '调用失败' , cls : 'bg-red-50 text-red-600 border-red-100' } ,
parse_error : { label : '解析失败' , cls : 'bg-amber-50 text-amber-700 border-amber-100' } ,
}
const SUFFICIENCY_LABEL : Record < string , string > = {
high : '充分' ,
medium : '一般' ,
low : '偏少' ,
none : '无' ,
}
function AgentCard ( { report : r } : { report : AgentReport } ) {
const badge = STATUS_BADGE [ r . status ] ? ? { label : r.status , cls : 'bg-ink-100 text-ink-500 border-ink-200' }
const inactive = r . status !== 'ok'
return (
< details className = "group rounded-card border border-ink-200 bg-white transition-colors open:border-ink-300 hover:border-ink-300" >
< summary className = "flex cursor-pointer list-none items-center gap-2.5 px-3.5 py-3" >
< svg viewBox = "0 0 20 20" className = "h-3.5 w-3.5 flex-shrink-0 text-ink-400 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 className = "text-sm font-medium text-ink-800" > { AGENT_LABELS [ r . agent ] ? ? r . agent } < / span >
< span className = { ` pill border ${ badge . cls } ` } > { badge . label } < / span >
< span className = "ml-auto flex items-center gap-3 text-2xs tabular-nums text-ink-500" >
{ r . status === 'ok' && r . subjective_confidence !== null && (
< span > 信 心 { Math . round ( r . subjective_confidence * 100 ) } % < / span >
) }
{ r . status === 'ok' && r . probable_score && (
< span className = "rounded bg-ink-100 px-1.5 py-0.5 font-medium text-ink-600" > { r . probable_score } < / span >
) }
< / span >
< / summary >
< div className = "space-y-3 border-t border-ink-100 px-3.5 pb-3.5 pt-3" >
{ /* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */ }
{ inactive && (
< p className = "text-xs leading-relaxed text-ink-500" >
{ r . status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。' }
{ r . status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。' }
{ r . status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。' }
< / p >
) }
{ ! inactive && r . home_edge !== null && (
< div >
< div className = "mb-1.5 flex items-center justify-between text-2xs" >
< span className = "font-medium text-ink-500" > 主 队 优 势 < / span >
< span className = { ` font-semibold tabular-nums ${ r . home_edge > 0 ? 'text-brand-600' : r . home_edge < 0 ? 'text-amber-600' : 'text-ink-500' } ` } >
{ r . home_edge > 0 ? '+' : '' } { r . home_edge . toFixed ( 2 ) }
< / span >
< / div >
< EdgeBar value = { r . home_edge } / >
< div className = "mt-1 flex justify-between text-2xs text-ink-400" >
< span > 利 客 队 < / span >
< span > 利 主 队 < / span >
< / div >
< / div >
) }
{ r . analysis && (
< p className = "text-sm leading-relaxed text-ink-700" > { r . analysis } < / p >
) }
{ r . key_evidence . length > 0 && (
< ul className = "space-y-1.5" >
{ r . key_evidence . map ( ( e , i ) = > (
< li key = { i } className = "flex gap-2 text-xs leading-relaxed text-ink-600" >
< span className = "mt-1.5 h-1 w-1 flex-shrink-0 rounded-full bg-ink-300" / >
< span > { e } < / span >
< / li >
) ) }
< / ul >
) }
{ r . exp_home_goals !== null && r . exp_away_goals !== null && (
< div className = "flex items-center gap-2 rounded-lg bg-ink-50 px-3 py-2" >
< span className = "text-2xs font-medium text-ink-500" > 进 球 期 望 < / span >
< span className = "text-sm font-semibold tabular-nums text-ink-800" >
{ r . exp_home_goals . toFixed ( 1 ) } - { r . exp_away_goals . toFixed ( 1 ) }
< / span >
< / div >
) }
{ ! inactive && (
< div className = "flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-ink-100 pt-2.5 text-2xs text-ink-400" >
< span > 数 据 充 分 度 { SUFFICIENCY_LABEL [ r . data_sufficiency ] ? ? r . data_sufficiency } < / span >
< span className = "font-mono" > { r . model } < / span >
{ r . latency_ms !== null && < span className = "tabular-nums" > { r . latency_ms } ms < / span > }
< / div >
) }
< / div >
< / details >
)
}