feat: fill missing product pages and analytics proxy
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Failing after 3m36s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Has been skipped

This commit is contained in:
wooo
2026-06-18 15:10:53 +08:00
parent 46a5f67ed8
commit 80c7a939dc
10 changed files with 429 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function fetchJson(path: string) {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/${path}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`${path} 暫時無法回應`);
return response.json();
}
export default async function AgentVerificationPage() {
let verification: any = null;
let usage: any = null;
let error = '';
try {
[verification, usage] = await Promise.all([fetchJson('agent-verification'), fetchJson('gemini-usage')]);
} catch (err) {
error = err instanceof Error ? err.message : 'AI 驗證資料暫時無法讀取';
}
const checks = Array.isArray(verification?.checks) ? verification.checks : [];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]">AI </p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]">Codex + Gemini + NemoTron + </h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">AI </p>
</section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 md:grid-cols-3">
{[
['整體狀態', verification?.overall_label ?? '-', 'AI 復核結論'],
['可否正式上線', verification?.production_ready ? '可' : '不可', '仍需量化閘門'],
['Gemini 費用', usage ? `$${Number(usage.estimated_cost_usd ?? 0).toFixed(4)} / $${Number(usage.cap_usd ?? 5).toFixed(2)}` : '-', '超過上限會暫停'],
].map(([label, value, helper]) => <article key={label} className="panel-glow rounded-2xl p-5"><p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{helper}</p><p className="mt-3 text-2xl font-black text-[#7d2a15]">{value}</p><p className="mt-1 text-sm text-[#6f4f3c]">{label}</p></article>)}
</section>
<section className="grid gap-4 lg:grid-cols-2">
{checks.map((item: any) => (
<article key={item.agent} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">{item.agent}</p>
<h2 className="mt-2 text-xl font-black text-[#3f2f25]">{item.role}</h2>
<p className="mt-2 rounded-full bg-[#fff8e6] px-3 py-1 text-xs font-black text-[#7d2a15]">{item.status_label}</p>
<ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{(item.evidence ?? []).map((line: string) => <li key={line}>{line}</li>)}</ul>
</article>
))}
</section>
<Link href="/recommendation-readiness" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
export const dynamic = 'force-dynamic';
type RouteContext = {
params: Promise<{ path: string[] }>;
};
function buildBackendUrl(request: Request, path: string[]): string | null {
if (!path.length || path.some((segment) => !segment || segment.includes('..'))) {
return null;
}
const source = new URL(request.url);
const target = new URL(`/analytics/${path.map(encodeURIComponent).join('/')}`, ANALYTICS_BACKEND);
target.search = source.search;
return target.toString();
}
async function proxyAnalytics(request: Request, context: RouteContext, method: 'GET' | 'POST') {
const { path } = await context.params;
const backendUrl = buildBackendUrl(request, path);
if (!backendUrl) {
return NextResponse.json({ message: '分析 API 路徑不合法' }, { status: 400 });
}
try {
const response = await fetch(backendUrl, {
method,
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
},
body: method === 'POST' ? await request.text() : undefined,
});
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const data = await response.json();
return NextResponse.json(data, { status: response.status });
}
const text = await response.text();
return NextResponse.json({ message: text || '分析服務暫時無法回應' }, { status: response.status });
} catch (error) {
const message = error instanceof Error ? error.message : '分析服務暫時無法連線';
return NextResponse.json({ message }, { status: 502 });
}
}
export async function GET(request: Request, context: RouteContext) {
return proxyAnalytics(request, context, 'GET');
}
export async function POST(request: Request, context: RouteContext) {
return proxyAnalytics(request, context, 'POST');
}

View File

@@ -0,0 +1,30 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMatches() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { cache: 'no-store' });
if (!response.ok) throw new Error('對戰情報 API 暫時無法回應');
return response.json();
}
function timeText(value: string) {
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
}
export default async function BattleRoomPage() {
let matches: any[] = [];
let error = '';
try { matches = await getMatches(); } catch (err) { error = err instanceof Error ? err.message : '對戰情報暫時無法讀取'; }
const now = Date.now();
const rows = matches.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 1000 * 60 * 60 * 24).slice(0, 18);
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8"><p className="dot-matrix text-sm font-bold text-[#b83822]"></p><h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1><p className="mt-3 text-sm leading-7 text-[#6f4f3c]"></p></section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 lg:grid-cols-3">{rows.map((match) => <Link key={match.match_id} href={`/matches/${match.match_id}`} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 transition hover:border-[#b83822]"><p className="text-xs font-bold text-[#b83822]">{timeText(match.kickoff_utc)}{match.status}</p><h2 className="mt-2 text-xl font-black text-[#3f2f25]">{match.home_team} vs {match.away_team}</h2><p className="mt-3 text-sm leading-6 text-[#7a5b46]"></p></Link>)}</section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMatches() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { cache: 'no-store' });
if (!response.ok) throw new Error('比分 API 暫時無法回應');
return response.json();
}
function timeText(value: string) {
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
}
export default async function LiveScorePage() {
let matches: any[] = [];
let error = '';
try { matches = await getMatches(); } catch (err) { error = err instanceof Error ? err.message : '比分暫時無法讀取'; }
const now = Date.now();
const rows = matches.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 1000 * 60 * 60 * 48).slice(0, 24);
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8"><p className="dot-matrix text-sm font-bold text-[#b83822]"></p><h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1><p className="mt-3 text-sm leading-7 text-[#6f4f3c]"></p></section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 lg:grid-cols-2">{rows.map((match) => <Link key={match.match_id} href={`/matches/${match.match_id}`} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 transition hover:border-[#b83822]"><p className="text-xs font-bold text-[#b83822]">{timeText(match.kickoff_utc)}{match.status}</p><h2 className="mt-2 text-xl font-black text-[#3f2f25]">{match.home_team} vs {match.away_team}</h2><p className="mt-3 text-3xl font-black text-[#7d2a15]">{match.home_score ?? '-'} : {match.away_score ?? '-'}</p><p className="mt-2 text-sm text-[#7a5b46]">{match.venue_city ?? '場地待定'}</p></Link>)}</section>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMarketCoverage() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/market-coverage`, { cache: 'no-store' });
if (!response.ok) throw new Error('盤口覆蓋 API 暫時無法回應');
return response.json();
}
export default async function MarketCoveragePage() {
let data: any = null;
let error = '';
try { data = await getMarketCoverage(); } catch (err) { error = err instanceof Error ? err.message : '盤口覆蓋暫時無法讀取'; }
const coverage = Array.isArray(data?.coverage) ? data.coverage : [];
const missing = Array.isArray(data?.missing_markets) ? data.missing_markets : [];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]"></p>
</section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 lg:grid-cols-3">
{coverage.map((item: any) => (
<article key={item.market_type} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">{item.label ?? item.market_type}</p>
<p className="mt-3 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.match_count}</b> </p>
<p className="mt-1 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.bookmaker_count}</b> </p>
<p className="mt-1 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.odds_rows}</b></p>
<p className={`mt-3 rounded-full px-3 py-1 text-xs font-black ${item.passes_formal_minimum ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>{item.passes_formal_minimum ? '可進正式檢查' : '只能監控'}</p>
</article>
))}
</section>
<section className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{missing.length ? missing.map((item: any) => <p key={item.market_type} className="rounded-2xl bg-[#fff8e6] p-4 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.label}</b>{item.reason}</p>) : <p className="text-sm text-[#7a5b46]"></p>}
</div>
</section>
<Link href="/recommendation-readiness" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
</div>
);
}

View File

@@ -0,0 +1,34 @@
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
type NewsItem = { title?: string; url?: string; source?: string; publishedAt?: string; published_at?: string; summary?: string };
async function getNews() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/news-snapshot`, { cache: 'no-store' });
if (!response.ok) throw new Error('新聞快照 API 暫時無法回應');
return response.json();
}
function timeText(value?: string) {
if (!value) return '時間待定';
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
export default async function NewsPage() {
let data: any = null;
let error = '';
try { data = await getNews(); } catch (err) { error = err instanceof Error ? err.message : '新聞資料暫時無法讀取'; }
const items: NewsItem[] = Array.isArray(data?.items) ? data.items : Array.isArray(data?.news) ? data.news : [];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]"></p>
<p className="mt-4 rounded-full bg-white/70 px-4 py-2 text-sm font-bold text-[#7d2a15]">{data?.status_label ?? '新聞快照同步中'}</p>
</section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
{items.length ? <section className="grid gap-4 lg:grid-cols-2">{items.map((item) => <a key={item.url ?? item.title} href={item.url} target="_blank" rel="noreferrer" className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 transition hover:border-[#b83822]"><p className="text-lg font-black text-[#3f2f25]">{item.title}</p><p className="mt-2 text-xs text-[#7a5b46]">{item.source ?? '來源待定'}{timeText(item.publishedAt ?? item.published_at)}</p>{item.summary ? <p className="mt-3 text-sm leading-6 text-[#6f4f3c]">{item.summary}</p> : null}</a>)}</section> : <section className="rounded-3xl border border-dashed border-[#d8b58c] bg-white/70 p-6"><p className="text-sm leading-7 text-[#7a5b46]">{data?.message ?? '目前沒有可公開引用的新聞快照。'}</p></section>}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getPerformance() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/recommendation-performance`, { cache: 'no-store' });
if (!response.ok) throw new Error('賽後校準 API 暫時無法回應');
return response.json();
}
export default async function RecommendationPerformancePage() {
let data: any = null;
let error = '';
try { data = await getPerformance(); } catch (err) { error = err instanceof Error ? err.message : '賽後校準暫時無法讀取'; }
const buckets = Array.isArray(data?.by_market_type) ? data.by_market_type : [];
const actions = Array.isArray(data?.improvement_actions) ? data.improvement_actions : [];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">調</p>
</section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 md:grid-cols-4">
{[
['已結算', data?.settled_recommendation_count ?? '-', '可判定推薦'],
['命中', data?.hit_count ?? '-', '命中筆數'],
['未中', data?.miss_count ?? '-', '未中筆數'],
['命中率', typeof data?.hit_rate_percent === 'number' ? `${data.hit_rate_percent.toFixed(2)}%` : '-', '近端表現'],
].map(([label, value, helper]) => <article key={label} className="panel-glow rounded-2xl p-5"><p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{helper}</p><p className="mt-3 text-3xl font-black text-[#7d2a15]">{value}</p><p className="mt-1 text-sm text-[#6f4f3c]">{label}</p></article>)}
</section>
<p className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5 text-sm leading-7 text-[#6f4f3c]">{data?.summary ?? '尚無摘要。'}</p>
<section className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p><div className="mt-3 grid gap-3 md:grid-cols-2 lg:grid-cols-3">{buckets.map((item: any) => <article key={item.market_type} className="rounded-2xl bg-[#fff8e6] p-4 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.market_type}</b><br /> {item.settled_count} {item.hit_count} {item.hit_rate_percent}%</article>)}</div></section>
<section className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]">調</p><ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{actions.map((item: string) => <li key={item}>{item}</li>)}</ul></section>
<Link href="/proof-of-yield" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getReadiness() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/recommendation-readiness`, { cache: 'no-store' });
if (!response.ok) throw new Error('推薦就緒度 API 暫時無法回應');
return response.json();
}
export default async function RecommendationReadinessPage() {
let data: any = null;
let error = '';
try { data = await getReadiness(); } catch (err) { error = err instanceof Error ? err.message : '推薦就緒度暫時無法讀取'; }
const marketStatus = Array.isArray(data?.market_status) ? data.market_status : [];
const blockers = Array.isArray(data?.blocking_reasons) ? data.blocking_reasons : [];
const actions = Array.isArray(data?.required_actions) ? data.required_actions : [];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">使</p>
<p className={`mt-5 inline-flex rounded-full px-4 py-2 text-sm font-black ${data?.formal_recommendations_allowed ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>{data?.headline ?? '就緒度同步中'}</p>
</section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 lg:grid-cols-3">
{marketStatus.map((item: any) => (
<article key={item.market_type} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">{item.label}</p>
<p className="mt-3 text-sm text-[#7a5b46]"> {item.match_count} {item.bookmaker_count} </p>
<p className="mt-1 text-sm text-[#7a5b46]"> {item.odds_rows}</p>
<p className={`mt-3 rounded-full px-3 py-1 text-xs font-black ${item.passes_minimum ? 'bg-[#e9f8ef] text-[#167a47]' : 'bg-[#fff7d6] text-[#8a6400]'}`}>{item.passes_minimum ? '通過最低門檻' : '未通過最低門檻'}</p>
</article>
))}
</section>
<section className="grid gap-4 lg:grid-cols-2">
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p><ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{blockers.map((item: string) => <li key={item}>{item}</li>)}</ul></article>
<article className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><p className="dot-matrix text-sm font-bold text-[#7d2a15]"></p><ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{actions.map((item: string) => <li key={item}>{item}</li>)}</ul></article>
</section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMatches() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { cache: 'no-store' });
if (!response.ok) throw new Error('賽程 API 暫時無法回應');
return response.json();
}
function taipeiDate(value: string) {
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', month: '2-digit', day: '2-digit', weekday: 'short' }).format(new Date(value));
}
function taipeiTime(value: string) {
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
}
export default async function SchedulePage() {
let matches: any[] = [];
let error = '';
try { matches = await getMatches(); } catch (err) { error = err instanceof Error ? err.message : '賽程暫時無法讀取'; }
const grouped = matches.reduce<Record<string, any[]>>((acc, match) => {
const key = taipeiDate(match.kickoff_utc);
acc[key] = [...(acc[key] ?? []), match];
return acc;
}, {});
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8"><p className="dot-matrix text-sm font-bold text-[#b83822]"></p><h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1><p className="mt-3 text-sm leading-7 text-[#6f4f3c]">便</p></section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
{Object.entries(grouped).map(([date, rows]) => <section key={date} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5"><h2 className="dot-matrix text-lg font-bold text-[#7d2a15]">{date}{rows.length} </h2><div className="mt-4 grid gap-3">{rows.map((match) => <Link key={match.match_id} href={`/matches/${match.match_id}`} className="rounded-2xl border border-[#eadcb9] bg-[#fff8e6] p-4 transition hover:border-[#b83822]"><p className="font-black text-[#3f2f25]">{taipeiTime(match.kickoff_utc)}{match.home_team} vs {match.away_team}</p><p className="mt-1 text-sm text-[#7a5b46]">{match.status}{match.home_score ?? '-'} : {match.away_score ?? '-'}{match.venue_city ?? '場地待定'}</p></Link>)}</div></section>)}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getSourceHealth() {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/source-health`, { cache: 'no-store' });
if (!response.ok) throw new Error('資料健康 API 暫時無法回應');
return response.json();
}
function dateText(value?: string | null) {
if (!value) return '尚無時間';
return new Intl.DateTimeFormat('zh-TW', { timeZone: 'Asia/Taipei', dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value));
}
export default async function SourceHealthPage() {
let data: any = null;
let error = '';
try { data = await getSourceHealth(); } catch (err) { error = err instanceof Error ? err.message : '資料健康暫時無法讀取'; }
const cards = [
['賽事總數', data?.matches ?? '-', '目前資料庫可見賽事'],
['已完賽', data?.finished_matches ?? '-', '可用於賽後校準'],
['賠率列數', data?.odds_rows ?? '-', '盤口歷史資料量'],
['逾時賽果', data?.stale_unsettled_matches ?? '-', '不為 0 時不升級正式推薦'],
];
return (
<div className="space-y-6">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 md:p-8">
<p className="dot-matrix text-sm font-bold text-[#b83822]"></p>
<h1 className="mt-3 text-4xl font-black text-[#3f2f25]"></h1>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]"></p>
</section>
{error ? <p className="rounded-2xl border border-[#e7a49a] bg-[#fff0e8] p-4 text-sm font-bold text-[#b83822]">{error}</p> : null}
<section className="grid gap-4 md:grid-cols-4">
{cards.map(([label, value, helper]) => (
<article key={label} className="panel-glow rounded-2xl p-5">
<p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{helper}</p>
<p className="mt-3 text-3xl font-black text-[#7d2a15]">{value}</p>
<p className="mt-1 text-sm text-[#6f4f3c]">{label}</p>
</article>
))}
</section>
<section className="grid gap-4 lg:grid-cols-3">
{[
['賠率同步', data?.ingestion_status?.status ?? data?.status, data?.ingestion_status?.message ?? `最近更新:${dateText(data?.latest_odds_recorded_at)}`],
['賽程同步', data?.fixtures_status?.status ?? 'unknown', data?.fixtures_status?.message ?? `最近排程:${dateText(data?.fixtures_status?.run_at)}`],
['新聞同步', data?.news_status?.status ?? 'unknown', data?.news_status?.message ?? `最近排程:${dateText(data?.news_status?.run_at)}`],
].map(([title, status, detail]) => (
<article key={title} className="rounded-3xl border border-[#eadcb9] bg-white/75 p-5">
<p className="dot-matrix text-sm font-bold text-[#7d2a15]">{title}</p>
<p className="mt-2 text-2xl font-black text-[#3f2f25]">{status || 'unknown'}</p>
<p className="mt-2 text-sm leading-6 text-[#7a5b46]">{detail}</p>
</article>
))}
</section>
<Link href="/daily-card" className="inline-flex rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white"></Link>
</div>
);
}