Files
2026FIFAWorldCup/platform/web/app/matches/[matchId]/page.tsx

376 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { format } from 'date-fns';
import { OddsLineMovementChart } from '@/components/OddsLineMovementChart';
import { MatchConditionsCard } from '@/components/MatchConditionsCard';
import type {
MatchConditionsReadout,
MatchDetail,
MatchListItem,
MatchOddsPoint,
MatchPoisson,
} from '@/lib/analytics-api';
import { formatToTaipeiTime } from '@/lib/timezone';
export const revalidate = 60;
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
async function fetchMatchList(): Promise<MatchListItem[]> {
try {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, {
headers: { 'Content-Type': 'application/json' },
cache: 'force-cache',
next: { revalidate: 60 },
});
if (!response.ok) {
return [];
}
return (await response.json()) as MatchListItem[];
} catch {
return [];
}
}
async function fetchMatchDetail(matchId: string): Promise<MatchDetail | null> {
try {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches/${matchId}`, {
headers: { 'Content-Type': 'application/json' },
cache: 'force-cache',
next: { revalidate: 60 },
});
if (!response.ok) {
return null;
}
return (await response.json()) as MatchDetail;
} catch {
return null;
}
}
function fallbackSummary(detail: MatchDetail): string {
const { home_team: home, away_team: away, poisson, conditions } = detail;
const homeWin = (poisson.one_x_two.home_win * 100).toFixed(1);
const draw = (poisson.one_x_two.draw * 100).toFixed(1);
const awayWin = (poisson.one_x_two.away_win * 100).toFixed(1);
const overProb = (poisson.over_under_2_5.over * 100).toFixed(1);
const underProb = (poisson.over_under_2_5.under * 100).toFixed(1);
return `【量化總結】
${home} vs ${away} 的 1x2 機率分佈顯示主勝 ${homeWin}%,平局 ${draw}%,客勝 ${awayWin}%。
Poisson 預測給出主隊 ${poisson.expected_home_goals.toFixed(2)} / 客隊 ${poisson.expected_away_goals.toFixed(2)} 的進球走勢。
賽事在 2.5 球上下行為上Over 機率 ${overProb}%Under 機率 ${underProb}%。
條件面顯示裁判嚴厲度 ${conditions.strictness_index.toFixed(1)},熱指數 ${conditions.heat_index.toFixed(1)}
若出現高張力紅黃牌壓力,建議觀察 1x2 讓盤與 Cards/Under 結構,避免逆風追買。`;
}
async function buildQuantSummaryWithLLM(detail: MatchDetail): Promise<string> {
const fallback = fallbackSummary(detail);
if (!OPENAI_API_KEY) {
return fallback;
}
try {
const prompt = `請以中文繁體為台灣使用者生成 300 字的世界盃賽前量化總結,嚴格使用專業投注語氣,不超過 420 字。
請使用以下數據:
- 主場 ${detail.home_team}xG ${detail.home_xg.toFixed(2)}
- 客場 ${detail.away_team}xG ${detail.away_xg.toFixed(2)}
- 1x2 機率: 主勝 ${detail.poisson.one_x_two.home_win.toFixed(4)}、平 ${detail.poisson.one_x_two.draw.toFixed(4)}、客勝 ${detail.poisson.one_x_two.away_win.toFixed(4)}
- Over/Under(2.5): ${(detail.poisson.over_under_2_5.over * 100).toFixed(1)} / ${(detail.poisson.over_under_2_5.under * 100).toFixed(1)}
- 裁判嚴厲度 ${detail.conditions.strictness_index.toFixed(1)}Heat Index ${detail.conditions.heat_index.toFixed(1)},下半場主/客攻擊 ${detail.conditions.second_half_home_attack.toFixed(2)} / ${detail.conditions.second_half_away_attack.toFixed(2)}`;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content: '你是精通運彩與量化投注的專業分析師。請只輸出純文字,不要 markdown。',
},
{
role: 'user',
content: prompt,
},
],
temperature: 0.3,
}),
});
if (!response.ok) {
return fallback;
}
const payload = await response.json();
const text = payload?.choices?.[0]?.message?.content;
return typeof text === 'string' && text.trim().length > 0 ? text.trim() : fallback;
} catch {
return fallback;
}
}
type Params = { matchId: string };
export async function generateStaticParams(): Promise<Array<Params>> {
const matches = await fetchMatchList();
return matches
.filter((item) => Boolean(item.match_id))
.map((item) => ({ matchId: item.match_id }));
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { matchId } = await params;
const match = await fetchMatchDetail(matchId);
const title = match
? `[資料驅動] 2026世界盃${match.home_team} vs ${match.away_team} 預測、傷停與聰明錢流向分析`
: `2026 世界盃賽事預測`;
const description = match
? `量化模型基於 xG、裁判與天候模型產出「${match.home_team} vs ${match.away_team}」完整賠率走勢與下注偏差訊號,含 2.5 球與波膽機率。`
: '2026 世界盃賽事量化預測、聰明錢流向與賠率走勢頁。';
return {
title,
description,
openGraph: {
type: 'article',
title,
description,
locale: 'zh_TW',
siteName: '2026 FIFA Quantum Ops',
},
};
}
function buildScoreMatrixRows(scoreMatrix: MatchPoisson['score_matrix']) {
return scoreMatrix.slice(0, 6).map((row, homeGoals) => ({
homeGoals,
values: row.slice(0, 6),
}));
}
function buildOddsChartRows(points: MatchOddsPoint[]) {
return points
.filter((row) => row.market_type === '1x2')
.map((row) => ({
time: format(new Date(row.recorded_at), 'HH:mm:ss'),
bookmaker: row.bookmaker,
odds: Number(row.decimal_odds),
}));
}
function buildSportsEventJsonLd(detail: MatchDetail) {
return {
'@context': 'https://schema.org',
'@type': 'SportsEvent',
name: `${detail.home_team} vs ${detail.away_team} 預測頁`,
startDate: detail.match_time_utc,
homeTeam: {
'@type': 'SportsTeam',
name: detail.home_team,
},
awayTeam: {
'@type': 'SportsTeam',
name: detail.away_team,
},
location: {
'@type': 'Place',
name: detail.venue_name,
address: {
'@type': 'PostalAddress',
addressLocality: detail.venue_city,
addressCountry: detail.venue_country,
},
},
description: detail.quant_summary,
aggregateRating: {
'@type': 'AggregateRating',
ratingValue: (detail.poisson.one_x_two.home_win * 100).toFixed(1),
bestRating: '100',
worstRating: '0',
ratingCount: 1,
},
};
}
function buildDatasetJsonLd(detail: MatchDetail) {
return {
'@context': 'https://schema.org',
'@type': 'Dataset',
name: `${detail.home_team} vs ${detail.away_team} 量化推論資料集`,
description: `2026世界盃賽事模型資料。包含主客 xG、賠率時序、裁判與熱指數、波膽機率。`,
creator: {
'@type': 'Organization',
name: '2026 FIFA Quantum Ops',
},
license: 'Proprietary',
temporalCoverage: detail.match_time_utc,
variableMeasured: [
{
'@type': 'PropertyValue',
name: 'xG',
value: `${detail.home_xg.toFixed(2)} / ${detail.away_xg.toFixed(2)}`,
},
{
'@type': 'PropertyValue',
name: 'Poisson 1X2',
value: JSON.stringify(detail.poisson.one_x_two),
},
{
'@type': 'PropertyValue',
name: 'Heat Index',
value: detail.conditions.heat_index,
},
],
};
}
export default async function MatchDetailPage({ params }: { params: Promise<Params> }) {
const { matchId } = await params;
const detail = await fetchMatchDetail(matchId);
if (!detail) {
notFound();
}
const quantSummary = await buildQuantSummaryWithLLM(detail);
const oddsRows = buildOddsChartRows(detail.odds_series);
const scoreRows = buildScoreMatrixRows(detail.poisson.score_matrix);
const kickoffLocal = formatToTaipeiTime(detail.match_time_utc, 'yyyy-MM-dd HH:mm:ss');
const isFinished = detail.status === 'finished';
const conditions: MatchConditionsReadout = detail.conditions;
const sportsEventJsonLd = JSON.stringify(buildSportsEventJsonLd(detail));
const datasetJsonLd = JSON.stringify(buildDatasetJsonLd(detail));
return (
<div className="space-y-4">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: sportsEventJsonLd }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: datasetJsonLd }}
/>
<section className="panel-glow rounded-2xl p-4">
<p className="dot-matrix text-xs text-[#b83822]">/matches/{matchId}</p>
<h1 className="mt-2 text-2xl text-[#7d2a15]">{detail.home_team} vs {detail.away_team}</h1>
<p className="mt-1 text-sm text-[#7a5b46]">
{kickoffLocal}{detail.venue_name}
{detail.venue_altitude_meters ? `(海拔 ${detail.venue_altitude_meters}m` : null}
</p>
<p className="mt-1 text-xs text-[#8a6b58]">{detail.status.toUpperCase()}</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<article className="rounded-xl border border-[#e8cead] bg-white/70 p-3">
<p className="text-xs text-[#7d4d39]"> xG</p>
<p className="dot-matrix text-2xl text-[#7d2a15]">
{detail.home_xg.toFixed(2)} : {detail.away_xg.toFixed(2)}
</p>
</article>
<article className="rounded-xl border border-[#e8cead] bg-white/70 p-3">
<p className="text-xs text-[#7d4d39]">1X2 </p>
<p className="dot-matrix text-sm text-[#7d2a15]">
{ (detail.poisson.one_x_two.home_win * 100).toFixed(1)}% · { (detail.poisson.one_x_two.draw * 100).toFixed(1)}% · { (detail.poisson.one_x_two.away_win * 100).toFixed(1)}%
</p>
</article>
</div>
<p className="mt-4 text-sm leading-7 text-[#6f4f3c]">{quantSummary}</p>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div>
<OddsLineMovementChart data={oddsRows} />
</div>
<MatchConditionsCard
matchId={detail.match_id}
strictnessIndex={conditions.strictness_index}
heatIndex={conditions.heat_index}
cardsPressureAlert={conditions.cards_pressure_alert}
secondHalfHomeAttack={conditions.second_half_home_attack}
secondHalfAwayAttack={conditions.second_half_away_attack}
second_half_under_recommendation={conditions.second_half_under_recommendation}
attackerDirection={conditions.attacker_direction}
/>
</section>
<section className="panel-glow rounded-2xl p-4">
<h2 className="dot-matrix text-xl text-[#7d2a15]">05 </h2>
<div className="mt-3 overflow-x-auto">
<table className="min-w-[560px] text-xs text-[#5f4330] md:text-sm">
<thead>
<tr>
<th className="sticky left-0 border border-[#e8cead] bg-[#fff4df] p-2">\</th>
{[0, 1, 2, 3, 4, 5].map((awayGoals) => (
<th key={`h${awayGoals}`} className="border border-[#e8cead] bg-[#fff4df] p-2">
{awayGoals}
</th>
))}
</tr>
</thead>
<tbody>
{scoreRows.map((row) => (
<tr key={`row-${row.homeGoals}`}>
<td className="border border-[#e8cead] bg-[#fff9eb] p-2"> {row.homeGoals}</td>
{row.values.map((value, idx) => {
const probability = Math.max(0, Math.min(1, value));
return (
<td
key={`cell-${row.homeGoals}-${idx}`}
className="border border-[#eed4b6] p-2"
style={{ backgroundColor: `rgba(184, 56, 34, ${probability * 0.8})`, color: probability > 0.15 ? '#fff' : '#5b3d2d' }}
>
{(probability * 100).toFixed(2)}%
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-[#7a5b46]">
Under/Over 2.5{(detail.poisson.over_under_2_5.under * 100).toFixed(1)}% / { (detail.poisson.over_under_2_5.over * 100).toFixed(1)}%
</p>
</section>
<section className={`rounded-2xl p-4 ${isFinished ? 'border border-[#dcb53b] bg-[#fff2da]' : 'border border-[#e3c08f] bg-white/80'}`}>
<p className="dot-matrix text-lg text-[#7d2a15]"></p>
<p className="mt-2 text-sm text-[#684834]">
{isFinished ? '盤口是否已結算' : '即場賠率變動與 Sharp Money 流向'}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link
href="/proof-of-yield"
className="dot-matrix rounded-full bg-[#7d2a15] px-4 py-2 text-white transition hover:bg-[#5f250f]"
>
</Link>
<Link
href="/daily-card"
className="dot-matrix rounded-full bg-white px-4 py-2 text-[#7d2a15] ring-1 ring-[#c58b63] transition hover:bg-[#fff2d9]"
>
</Link>
</div>
</section>
</div>
);
}