383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
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]">泊松波膽矩陣(0–5 球)</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>
|
||
);
|
||
}
|