P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。
P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。
P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
248 lines
9.4 KiB
TypeScript
248 lines
9.4 KiB
TypeScript
/**
|
|
* MatchPredictPanel: 预测弹窗全套(过程可视化 / 结果版面 / 专家意见)。
|
|
*
|
|
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
|
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
|
*/
|
|
import { useEffect, useState } from 'react'
|
|
import TeamSideTag from '../../../components/TeamSideTag'
|
|
import type { Match, Prediction } from '../types'
|
|
import { AGENT_LABELS } from '../types'
|
|
import { AgentsPanel } from './AgentsPanel'
|
|
import { OutcomePanel } from './OutcomePanel'
|
|
import { ReasoningPanel } from './ReasoningPanel'
|
|
|
|
function Spinner({ className = '' }: { className?: string }) {
|
|
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="1.5" strokeOpacity="0.25" />
|
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
|
.**
|
|
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
|
|
* OutcomePanel(比分/胜平负/成本) / AgentsPanel(专家意见) / ReasoningPanel(终裁/降级)。
|
|
* 渲染输出与拆分前完全一致(仅降级警示 + 报头 + 元信息仍在此处)。
|
|
*/
|
|
function PredictionPanel({
|
|
prediction,
|
|
match,
|
|
embedded = false,
|
|
}: {
|
|
prediction: Prediction
|
|
match: Match
|
|
/** 弹窗嵌入模式:弹窗已提供报头,这里省略自带版头 */
|
|
embedded?: boolean
|
|
}) {
|
|
const homeName = match.home_team_zh || match.home_team
|
|
const awayName = match.away_team_zh || match.away_team
|
|
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
|
const reports = prediction.agent_outputs ?? []
|
|
const okReports = reports.filter(r => r.status === 'ok')
|
|
|
|
return (
|
|
<article className={embedded ? 'bg-paper-50' : 'border border-ink-900 bg-paper-50'}>
|
|
{!embedded && (
|
|
<div className="flex flex-wrap items-baseline justify-between gap-2 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">
|
|
预测版 ·
|
|
<TeamSideTag side="home" />
|
|
{homeName}
|
|
<span>对</span>
|
|
<TeamSideTag side="away" />
|
|
{awayName}
|
|
</h3>
|
|
<span className="text-2xs tabular-nums text-ink-500">
|
|
{prediction.provider} / {prediction.model}
|
|
{prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-7 px-4 py-6 sm:px-5">
|
|
{degraded && (
|
|
<div className="border-l-2 border-press bg-press-wash/40 px-4 py-3">
|
|
<p className="font-serif text-sm font-bold text-press-dark">
|
|
{prediction.status === 'failed' ? '预测失败' : '预测降级(degraded)'}
|
|
</p>
|
|
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
|
{prediction.reasoning || '所有专家均无有效数据或调用失败,无法生成可靠比分。'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
|
|
|
<p className="text-center text-2xs text-ink-500">
|
|
`多专家模式 · ${okReports.length}/${reports.length} 路有效`
|
|
{prediction.prompt_version && ` · prompt ${prediction.prompt_version}`}
|
|
</p>
|
|
|
|
<AgentsPanel prediction={prediction} />
|
|
|
|
<ReasoningPanel prediction={prediction} />
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
function PredictProgress() {
|
|
const [elapsed, setElapsed] = useState(0)
|
|
useEffect(() => {
|
|
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
|
return () => clearInterval(t)
|
|
}, [])
|
|
|
|
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
|
const SLICE_END = 3
|
|
const AGENT_START = 4
|
|
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
|
const AGG_START = AGENT_START + AGENT_STEP * 5
|
|
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
|
|
|
const phase = elapsed < SLICE_END ? 'slice'
|
|
: elapsed < AGG_START ? 'agents' : 'agg'
|
|
|
|
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
|
|
|
return (
|
|
<div className="px-5 py-8 sm:px-8">
|
|
{/* 阶段标题 */}
|
|
<div className="flex items-center justify-center gap-2">
|
|
<Spinner className="text-press" />
|
|
<span className="font-serif text-sm font-bold text-ink-900">
|
|
{phase === 'slice' && '正在组装比赛数据切片'}
|
|
{phase === 'agents' && '五路专家并行分析中'}
|
|
{phase === 'agg' && '终裁专家汇总裁定中'}
|
|
</span>
|
|
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
|
</div>
|
|
|
|
{/* 进度条:渐进式,不封顶到 100% */}
|
|
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
|
<div
|
|
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* 专家灯序(多专家模式) */}
|
|
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
|
{agents.map((a, i) => {
|
|
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
|
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
|
return (
|
|
<li
|
|
key={a}
|
|
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
|
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
|
}`}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<span
|
|
aria-hidden="true"
|
|
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
|
/>
|
|
{AGENT_LABELS[a] ?? a}
|
|
</span>
|
|
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
|
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
|
|
<p className="mt-6 text-center text-2xs text-ink-400">
|
|
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。关闭窗口即取消
|
|
</p>
|
|
<p className="mt-1 text-center text-2xs text-ink-300">
|
|
提示:每分钟限 10 次预测,耗尽后需等待下一分钟。
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
export function PredictModal({
|
|
match,
|
|
predicting,
|
|
prediction,
|
|
error,
|
|
onClose,
|
|
}: {
|
|
match: Match
|
|
predicting: boolean
|
|
prediction: Prediction | null
|
|
error: string | null
|
|
onClose: () => void
|
|
}) {
|
|
const homeName = match.home_team_zh || match.home_team
|
|
const awayName = match.away_team_zh || match.away_team
|
|
|
|
useEffect(() => {
|
|
const h = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', h)
|
|
return () => document.removeEventListener('keydown', h)
|
|
}, [onClose])
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
|
onClick={e => {
|
|
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 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">
|
|
预测版 ·
|
|
<TeamSideTag side="home" />
|
|
{homeName}
|
|
<span>对</span>
|
|
<TeamSideTag side="away" />
|
|
{awayName}
|
|
</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
|
aria-label="关闭"
|
|
>
|
|
<svg className="h-4 w-4" viewBox="0 0 20 20" 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>
|
|
|
|
{/* 弹窗体(小屏可滚动) */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{predicting ? (
|
|
<PredictProgress />
|
|
) : error ? (
|
|
<div className="px-5 py-10 text-center sm:px-8">
|
|
<p className="font-serif text-sm font-bold text-press">预测失败</p>
|
|
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
|
{error}
|
|
</p>
|
|
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
|
</div>
|
|
) : prediction ? (
|
|
<PredictionPanel prediction={prediction} match={match} embedded />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|