diff --git a/.gitignore b/.gitignore index cd393f0..4a74eba 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ frontend/dist/ CLAUDE.md docs/AGENTS.md docs/agents/ + +# 本地审查/预览脚手架(不入库) +.tools/ +.preview/ diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 2db3056..2e7af2b 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,5 +1,6 @@ export default { - content: ['./index.html', './src/**/*.{ts,tsx}'], - theme: { extend: {} }, - plugins: [], + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7495eb3..d06806d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,17 +1,65 @@ import { ErrorBoundary } from './components/ErrorBoundary' import Matches from './pages/Matches' +function Logo() { + // 纯 SVG 队徽/足球图形,替代 emoji ⚽(跨平台渲染不一致) + return ( + + + + ) +} + export default function App() { return ( -
-
-

⚽ 先知 Profeto

- 足球 LLM 预测服务 +
+
+
+
+ +
+

+ 先知 Profeto +

+

+ LLM 多专家协作 · 足球比分预测 +

+
+
+ + + + + + 服务运行中 + +
-
+ +
+ +
+

+ 预测结果由大语言模型生成,仅供研究参考,不构成任何投注建议 +

+
) diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..9845f6d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,98 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + html { + -webkit-text-size-adjust: 100%; + text-rendering: optimizeLegibility; + } + + body { + @apply bg-ink-100 text-ink-800 antialiased; + font-feature-settings: 'tnum' 1; /* 数字等宽:比分/百分比不跳动 */ + } + + /* 统一焦点环,键盘可达性 */ + :focus-visible { + @apply outline-none ring-2 ring-brand-500/40 ring-offset-1 ring-offset-white; + } + + /* 细滚动条 */ + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + @apply rounded-pill bg-ink-300/70; + } + ::-webkit-scrollbar-thumb:hover { + @apply bg-ink-400; + } + + /* 尊重「减少动态效果」偏好 */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } +} + +@layer components { + /* ── 卡片 ── */ + .card { + @apply rounded-card border border-ink-200 bg-white shadow-card; + } + + /* ── 按钮 ── */ + .btn { + @apply inline-flex items-center justify-center gap-1.5 rounded-lg px-3.5 py-2 + text-sm font-medium transition-colors duration-150 + disabled:cursor-not-allowed disabled:opacity-50; + } + .btn-primary { + @apply btn bg-brand-600 text-white shadow-sm hover:bg-brand-700 active:bg-brand-800; + } + .btn-ghost { + @apply btn border border-ink-200 bg-white text-ink-700 hover:bg-ink-50 hover:border-ink-300; + } + .btn-sm { + @apply px-3 py-1.5 text-xs; + } + + /* ── 表单控件 ── */ + .field { + @apply rounded-lg border border-ink-200 bg-white px-3 py-2 text-sm text-ink-800 + transition-colors hover:border-ink-300 + focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20; + } + + /* ── 状态胶囊 ── */ + .pill { + @apply inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-2xs font-medium; + } + + /* ── 骨架屏 ── */ + .skeleton { + @apply rounded bg-gradient-to-r from-ink-200 via-ink-100 to-ink-200 + bg-[length:200%_100%] animate-shimmer; + } + + /* ── 分段控件(模式切换) ── */ + .segment { + @apply rounded-md px-3 py-1.5 text-xs font-medium transition-colors duration-150; + } + .segment-on { + @apply bg-white text-brand-700 shadow-sm; + } + .segment-off { + @apply text-ink-500 hover:text-ink-700; + } +} diff --git a/frontend/src/pages/Matches.tsx b/frontend/src/pages/Matches.tsx index b63c69a..8940b8f 100644 --- a/frontend/src/pages/Matches.tsx +++ b/frontend/src/pages/Matches.tsx @@ -65,6 +65,96 @@ const LEAGUES = [ { code: 'F1', name: '法甲' }, ] +const STATUS_META: Record = { + 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 = { '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 ( + + ) +} + +/** 置信度条:把 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 ( +
+
+
+ ) +} + +/** 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 ( +
+ + = 0 ? 'bg-brand-500' : 'bg-amber-500'}`} + style={ + v >= 0 + ? { left: '50%', width: `${half}%` } + : { right: '50%', width: `${half}%` } + } + /> +
+ ) +} + +/** 骨架屏行,替代 emoji spinner */ +function SkeletonRows({ n = 4 }: { n?: number }) { + return ( + <> + {Array.from({ length: n }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} + + ) +} + 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(null) const [prediction, setPrediction] = useState(null) + const [predictionFor, setPredictionFor] = useState(null) const [error, setError] = useState(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 (
- {/* 筛选 */} -
- - -
- 模式: - - + {/* ── 工具栏 ── */} +
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ + 共 {matches.length} 场 + +
- - 共 {matches.length} 场
+ {/* ── 错误提示 ── */} {error && ( -
- ❌ {error} - -
- )} - - {/* 比赛表 */} -
- - - - - - - - - - - - - {loading && ( - - )} - {!loading && matches.length === 0 && ( - - )} - {matches.map(m => ( - - - - - - - - - ))} - -
日期主队客队比分状态操作
- 加载中... -
暂无数据,请先采集
{fmtDate(m.match_date)}{m.home_team_zh || m.home_team}{m.away_team_zh || m.away_team} - {m.home_goals !== null ? `${m.home_goals} - ${m.away_goals}` : '-'} - - - {m.match_status === 'finished' ? '完赛' : m.match_status === 'scheduled' ? '未开赛' : m.match_status} - - - -
-
- - {/* 分页: 加载更多 */} - {nextCursor && ( -
-
)} - {/* 预测中指示 */} - {predictingId && ( -
- - LLM 预测中,请稍候... + {/* ── 比赛列表 ── */} +
+ {loading && } + + {!loading && matches.length === 0 && ( +
+ +

暂无比赛数据

+

请先通过采集接口导入 {leagueName} 的赛程

+
+ )} + + {!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 ( +
+
+ {/* 对阵 */} +
+
+ {homeName} + +
+ +
+ {m.home_goals !== null && m.away_goals !== null ? ( + + {m.home_goals}:{m.away_goals} + + ) : ( + VS + )} + {m.home_xg !== null && m.away_xg !== null && ( + + xG {m.home_xg.toFixed(1)}-{m.away_xg.toFixed(1)} + + )} +
+ +
+ + {awayName} +
+
+ +
+ {/* 元信息:桌面端独立成列,移动端与按钮同排 */} +
+ + + {st.label} + + + {m.league_code ?? '—'} · {fmtDate(m.match_date)} + +
+ + {/* 操作:主功能提升为实心按钮 */} + +
+
+
+ ) + })} + + {!loading && nextCursor && ( +
+ +
+ )} +
+ + {/* ── 预测中:占位卡片(替代 emoji 提示条) ── */} + {predictingId && !prediction && ( +
+
+ + 正在生成预测 + + {mode === 'multi' ? '· 5 个专家并行分析后由终裁汇总,约需 20-60 秒' : '· 单次调用,约需 5-15 秒'} + +
+
+
+
+
+
+
+
+
+
+
+
+
)} - {/* 预测结果 */} - {prediction && ( -
-

🤖 LLM 预测结果

-
-
-
主进球
-
{prediction.pred_home_goals ?? '-'}
-
-
-
客进球
-
{prediction.pred_away_goals ?? '-'}
-
-
-
胜平负
-
{prediction.pred_1x2 ?? '-'}
-
-
-
置信度
-
- {prediction.subjective_confidence !== null ? `${(prediction.subjective_confidence * 100).toFixed(0)}%` : '-'} -
-
-
-
- {prediction.provider} / {prediction.model} · 耗时 {prediction.latency_ms}ms - {prediction.mode === 'multi' && ' · 多 Agent 模式'} -
+ {/* ── 预测结果 ── */} + {prediction && predictionFor && ( + + )} +
+ ) +} - {/* 各专家 agent 报告 */} - {prediction.agent_outputs && prediction.agent_outputs.length > 0 && ( -
-
专家 Agent 报告
- {prediction.agent_outputs.map((r) => ( -
- - - {AGENT_LABELS[r.agent] || r.agent} - {r.status !== 'ok' && ( - - {r.status === 'no_data' ? '无数据' : '失败'} - - )} - - - {r.home_edge !== null && ( - 0 ? 'text-blue-600' : r.home_edge < 0 ? 'text-amber-600' : ''}> - 主队优势 {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} - - )} - {r.subjective_confidence !== null && 信心 {(r.subjective_confidence * 100).toFixed(0)}%} - {r.probable_score && 比分 {r.probable_score}} - - -
- {r.analysis &&

{r.analysis}

} - {r.key_evidence.length > 0 && ( -
    - {r.key_evidence.map((e, i) =>
  • {e}
  • )} -
- )} - {r.exp_home_goals !== null && r.exp_away_goals !== null && ( -
- 进球期望: {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} -
- )} -
- 数据充分度 {r.data_sufficiency} · {r.model} · {r.latency_ms}ms -
-
-
- ))} -
- )} +function Spinner({ className = '' }: { className?: string }) { + // 纯 SVG 转圈,替代 emoji ⏳(各平台渲染不一致) + return ( + + ) +} - {prediction.reasoning && ( -
-
推理过程
-
{prediction.reasoning}
-
- )} -
- 查看完整上下文 -
-              {prediction.context}
-            
-
+/** 胜平负概率条: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 ( +
+

胜平负

+

{label}

+ {confidence !== null && ( +
+ +

主观置信度 {Math.round(confidence * 100)}%

)}
) } + +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 ( +
+ {/* 面板头 */} +
+
+ + + +

LLM 预测结果

+ {prediction.mode === 'multi' && ( + + 多 Agent · {okReports.length}/{prediction.agent_outputs?.length ?? 0} 专家有效 + + )} +
+ + {prediction.provider} / {prediction.model} + {prediction.latency_ms !== null && ` · ${(prediction.latency_ms / 1000).toFixed(1)}s`} + +
+ +
+ {/* ── 对阵 + 预测比分(核心) ── */} +
+
+ {homeName} + +
+ +
+
+ + {prediction.pred_home_goals ?? '-'} + + : + + {prediction.pred_away_goals ?? '-'} + +
+ 预测比分 +
+ +
+ + {awayName} +
+
+ + {/* ── 指标卡 ── */} +
+ +
+

预测模式

+

{mode === 'multi' ? '多 Agent' : '单次'}

+

+ prompt {prediction.prompt_version ?? '—'} +

+
+
+

模型耗时

+

+ {prediction.latency_ms !== null ? (prediction.latency_ms / 1000).toFixed(1) : '—'} + s +

+

含专家并行 + 终裁汇总

+
+
+ + {/* ── 专家报告 ── */} + {prediction.agent_outputs && prediction.agent_outputs.length > 0 && ( +
+
+

专家 Agent 报告

+ {prediction.agent_weights && ( + + 终裁权重: + {Object.entries(prediction.agent_weights) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${AGENT_LABELS[k] ?? k} ${Math.round(v * 100)}%`) + .join(' · ')} + + )} +
+ +
+ {prediction.agent_outputs.map(r => ( + + ))} +
+
+ )} + + {/* ── 推理过程 ── */} + {prediction.reasoning && ( +
+

终裁推理

+

{prediction.reasoning}

+
+ )} + + {/* ── 原始上下文 ── */} +
+ + + 查看喂给模型的完整数据切片 + +
+            {prediction.context}
+          
+
+
+
+ ) +} + +const STATUS_BADGE: Record = { + 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 = { + 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 ( +
+ + + + {AGENT_LABELS[r.agent] ?? r.agent} + + {badge.label} + + + {r.status === 'ok' && r.subjective_confidence !== null && ( + 信心 {Math.round(r.subjective_confidence * 100)}% + )} + {r.status === 'ok' && r.probable_score && ( + {r.probable_score} + )} + + + +
+ {/* 无数据 / 失败时给出明确说明,避免用户以为是空白 bug */} + {inactive && ( +

+ {r.status === 'no_data' && '该维度没有可用数据,已跳过 LLM 分析以节省额度(不影响其他专家)。'} + {r.status === 'error' && '该专家调用失败,本次结论未纳入其视角(fail-open 设计,不阻断整体预测)。'} + {r.status === 'parse_error' && '模型输出未通过格式校验,该报告已丢弃。'} +

+ )} + + {!inactive && r.home_edge !== null && ( +
+
+ 主队优势 + 0 ? 'text-brand-600' : r.home_edge < 0 ? 'text-amber-600' : 'text-ink-500'}`}> + {r.home_edge > 0 ? '+' : ''}{r.home_edge.toFixed(2)} + +
+ +
+ 利客队 + 利主队 +
+
+ )} + + {r.analysis && ( +

{r.analysis}

+ )} + + {r.key_evidence.length > 0 && ( +
    + {r.key_evidence.map((e, i) => ( +
  • + + {e} +
  • + ))} +
+ )} + + {r.exp_home_goals !== null && r.exp_away_goals !== null && ( +
+ 进球期望 + + {r.exp_home_goals.toFixed(1)} - {r.exp_away_goals.toFixed(1)} + +
+ )} + + {!inactive && ( +
+ 数据充分度 {SUFFICIENCY_LABEL[r.data_sufficiency] ?? r.data_sufficiency} + {r.model} + {r.latency_ms !== null && {r.latency_ms}ms} +
+ )} +
+
+ ) +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 97ed72b..19b5424 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -1,6 +1,83 @@ /** @type {import('tailwindcss').Config} */ export default { content: ['./index.html', './src/**/*.{ts,tsx}'], - theme: { extend: {} }, + theme: { + extend: { + colors: { + // 品牌主色:偏冷的蓝,比默认 blue-600 更沉稳,适合数据产品 + brand: { + 50: '#EFF6FF', + 100: '#DBEAFE', + 200: '#BFDBFE', + 300: '#93C5FD', + 400: '#60A5FA', + 500: '#3B82F6', + 600: '#2563EB', + 700: '#1D4ED8', + 800: '#1E40AF', + 900: '#1E3A8A', + 950: '#172554', + }, + // 中性色:带一点冷调,避免纯灰显得脏 + ink: { + 50: '#F8FAFC', + 100: '#F1F5F9', + 200: '#E2E8F0', + 300: '#CBD5E1', + 400: '#94A3B8', + 500: '#64748B', + 600: '#475569', + 700: '#334155', + 800: '#1E293B', + 900: '#0F172A', + }, + // 语义色:状态徽章 / 提示条统一走这套 + win: { bg: '#ECFDF5', fg: '#047857', line: '#A7F3D0' }, + draw: { bg: '#FFFBEB', fg: '#B45309', line: '#FDE68A' }, + loss: { bg: '#FEF2F2', fg: '#B91C1C', line: '#FECACA' }, + }, + fontFamily: { + sans: [ + 'ui-sans-serif', + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + '"PingFang SC"', + '"Hiragino Sans GB"', + '"Microsoft YaHei"', + 'sans-serif', + ], + mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'Consolas', 'monospace'], + }, + fontSize: { + // 多一档 2xs,给徽章/元信息用 + '2xs': ['11px', { lineHeight: '16px' }], + }, + borderRadius: { + card: '12px', + pill: '9999px', + }, + boxShadow: { + // 卡片阴影:极浅,靠层次而非重影提升 + card: '0 1px 2px 0 rgb(15 23 42 / 0.04), 0 1px 3px 0 rgb(15 23 42 / 0.06)', + raise: '0 4px 12px -2px rgb(15 23 42 / 0.08), 0 2px 6px -2px rgb(15 23 42 / 0.04)', + focus: '0 0 0 3px rgb(37 99 235 / 0.15)', + }, + keyframes: { + 'fade-up': { + '0%': { opacity: '0', transform: 'translateY(4px)' }, + '100%': { opacity: '1', transform: 'translateY(0)' }, + }, + shimmer: { + '0%': { backgroundPosition: '-200% 0' }, + '100%': { backgroundPosition: '200% 0' }, + }, + }, + animation: { + 'fade-up': 'fade-up 0.22s ease-out both', + shimmer: 'shimmer 1.4s linear infinite', + }, + }, + }, plugins: [], }