Files
2026FIFAWorldCup/platform/web/app/matches/[matchId]/page.tsx
QuantBot aa7e3bba76
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality & Testing (push) Failing after 1m49s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Rsync (push) Has been skipped
chore: migrate deployment to Gitea Actions with zero-trust rsync
2026-06-16 19:06:50 +08:00

383 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 { matchStatusKind, matchStatusLabel } from '@/lib/match-order';
import { formatToTaipeiTime } from '@/lib/timezone';
export const dynamic = 'force-dynamic';
export const revalidate = 0;
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: 'no-store',
});
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/${encodeURIComponent(matchId)}`, {
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
});
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.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}:預期進球 ${detail.home_xg.toFixed(2)}
- 客場 ${detail.away_team}:預期進球 ${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
? `模型根據預期進球、裁判尺度與天候條件,整理「${match.home_team} vs ${match.away_team}」的賠率走勢與可能偏差,包含大小 2.5 球與比分機率。`
: '2026 世界盃賽事量化預測、聰明錢流向與賠率走勢頁。';
return {
title,
description,
openGraph: {
type: 'article',
title,
description,
locale: 'zh_TW',
siteName: '2026 世界盃量化投注研究中心',
},
};
}
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世界盃賽事模型資料。包含主客預期進球、賠率走勢、裁判尺度、熱指數與比分機率。`,
creator: {
'@type': 'Organization',
name: '2026 世界盃量化投注研究中心',
},
license: 'Proprietary',
temporalCoverage: detail.match_time_utc,
variableMeasured: [
{
'@type': 'PropertyValue',
name: '預期進球',
value: `${detail.home_xg.toFixed(2)} / ${detail.away_xg.toFixed(2)}`,
},
{
'@type': 'PropertyValue',
name: '勝平負機率',
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 statusItem = {
kickoff_utc: detail.match_time_utc,
status: detail.status,
home_score: (detail as { home_score?: number | null }).home_score ?? null,
away_score: (detail as { away_score?: number | null }).away_score ?? null,
};
const statusLabel = matchStatusLabel(statusItem);
const isFinished = matchStatusKind(statusItem) === '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]">{statusLabel}</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]"></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} teamName={detail.home_team} />
</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}
secondHalfUnderRecommendation={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>
);
}