fix: type missing product pages
All checks were successful
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 4m25s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Successful in 5m22s

This commit is contained in:
wooo
2026-06-18 15:28:30 +08:00
parent 80c7a939dc
commit 1e082333af
9 changed files with 120 additions and 121 deletions

View File

@@ -2,22 +2,43 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function fetchJson(path: string) {
type AgentVerificationCheck = {
agent: string;
role: string;
status_label: string;
evidence?: string[];
};
type AgentVerificationResponse = {
overall_label?: string;
production_ready?: boolean;
checks?: AgentVerificationCheck[];
};
type GeminiUsageResponse = {
estimated_cost_usd?: number;
cap_usd?: number;
};
async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/${path}`, { cache: 'no-store' });
if (!response.ok) throw new Error(`${path} 暫時無法回應`);
return response.json();
return response.json() as Promise<T>;
}
export default async function AgentVerificationPage() {
let verification: any = null;
let usage: any = null;
let verification: AgentVerificationResponse | null = null;
let usage: GeminiUsageResponse | null = null;
let error = '';
try {
[verification, usage] = await Promise.all([fetchJson('agent-verification'), fetchJson('gemini-usage')]);
[verification, usage] = await Promise.all([
fetchJson<AgentVerificationResponse>('agent-verification'),
fetchJson<GeminiUsageResponse>('gemini-usage'),
]);
} catch (err) {
error = err instanceof Error ? err.message : 'AI 驗證資料暫時無法讀取';
}
const checks = Array.isArray(verification?.checks) ? verification.checks : [];
const checks = verification?.checks ?? [];
return (
<div className="space-y-6">
@@ -35,12 +56,12 @@ export default async function AgentVerificationPage() {
].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) => (
{checks.map((item) => (
<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>
<ul className="mt-3 space-y-2 text-sm leading-6 text-[#7a5b46]">{(item.evidence ?? []).map((line) => <li key={line}>{line}</li>)}</ul>
</article>
))}
</section>

View File

@@ -2,10 +2,12 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMatches() {
type MatchRow = { match_id: string; home_team: string; away_team: string; kickoff_utc: string; status: string };
async function getMatches(): Promise<MatchRow[]> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { cache: 'no-store' });
if (!response.ok) throw new Error('對戰情報 API 暫時無法回應');
return response.json();
return response.json() as Promise<MatchRow[]>;
}
function timeText(value: string) {
@@ -13,7 +15,7 @@ function timeText(value: string) {
}
export default async function BattleRoomPage() {
let matches: any[] = [];
let matches: MatchRow[] = [];
let error = '';
try { matches = await getMatches(); } catch (err) { error = err instanceof Error ? err.message : '對戰情報暫時無法讀取'; }
const now = Date.now();

View File

@@ -2,10 +2,12 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMatches() {
type MatchRow = { match_id: string; home_team: string; away_team: string; home_score: number | null; away_score: number | null; kickoff_utc: string; status: string; venue_city?: string | null };
async function getMatches(): Promise<MatchRow[]> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { cache: 'no-store' });
if (!response.ok) throw new Error('比分 API 暫時無法回應');
return response.json();
return response.json() as Promise<MatchRow[]>;
}
function timeText(value: string) {
@@ -13,7 +15,7 @@ function timeText(value: string) {
}
export default async function LiveScorePage() {
let matches: any[] = [];
let matches: MatchRow[] = [];
let error = '';
try { matches = await getMatches(); } catch (err) { error = err instanceof Error ? err.message : '比分暫時無法讀取'; }
const now = Date.now();

View File

@@ -2,18 +2,34 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMarketCoverage() {
type MarketCoverageItem = {
market_type: string;
label?: string;
odds_rows?: number;
match_count?: number;
bookmaker_count?: number;
passes_formal_minimum?: boolean;
};
type MissingMarket = { market_type: string; label?: string; reason?: string };
type MarketCoverageResponse = {
coverage?: MarketCoverageItem[];
missing_markets?: MissingMarket[];
};
async function getMarketCoverage(): Promise<MarketCoverageResponse> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/market-coverage`, { cache: 'no-store' });
if (!response.ok) throw new Error('盤口覆蓋 API 暫時無法回應');
return response.json();
return response.json() as Promise<MarketCoverageResponse>;
}
export default async function MarketCoveragePage() {
let data: any = null;
let data: MarketCoverageResponse | null = 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 : [];
const coverage = data?.coverage ?? [];
const missing = data?.missing_markets ?? [];
return (
<div className="space-y-6">
@@ -24,12 +40,12 @@ export default async function MarketCoveragePage() {
</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) => (
{coverage.map((item) => (
<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 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.match_count ?? 0}</b> </p>
<p className="mt-1 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.bookmaker_count ?? 0}</b> </p>
<p className="mt-1 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.odds_rows ?? 0}</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>
))}
@@ -37,7 +53,7 @@ export default async function MarketCoveragePage() {
<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>}
{missing.length ? missing.map((item) => <p key={item.market_type} className="rounded-2xl bg-[#fff8e6] p-4 text-sm text-[#7a5b46]"><b className="text-[#3f2f25]">{item.label ?? item.market_type}</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>

View File

@@ -1,11 +1,12 @@
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 };
type NewsSnapshot = { status_label?: string; message?: string; items?: NewsItem[]; news?: NewsItem[] };
async function getNews() {
async function getNews(): Promise<NewsSnapshot> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/news-snapshot`, { cache: 'no-store' });
if (!response.ok) throw new Error('新聞快照 API 暫時無法回應');
return response.json();
return response.json() as Promise<NewsSnapshot>;
}
function timeText(value?: string) {
@@ -14,19 +15,14 @@ function timeText(value?: string) {
}
export default async function NewsPage() {
let data: any = null;
let data: NewsSnapshot | null = 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 : [];
const items = data?.items ?? 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>
<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

@@ -2,38 +2,30 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getPerformance() {
type MarketBucket = { market_type: string; settled_count?: number; hit_count?: number; hit_rate_percent?: number };
type PerformanceResponse = { settled_recommendation_count?: number; hit_count?: number; miss_count?: number; hit_rate_percent?: number; summary?: string; by_market_type?: MarketBucket[]; improvement_actions?: string[] };
async function getPerformance(): Promise<PerformanceResponse> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/recommendation-performance`, { cache: 'no-store' });
if (!response.ok) throw new Error('賽後校準 API 暫時無法回應');
return response.json();
return response.json() as Promise<PerformanceResponse>;
}
export default async function RecommendationPerformancePage() {
let data: any = null;
let data: PerformanceResponse | null = 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 : [];
const buckets = data?.by_market_type ?? [];
const 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>
<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>
<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>
<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) => <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 ?? 0} {item.hit_count ?? 0} {item.hit_rate_percent ?? 0}%</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) => <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

@@ -2,43 +2,29 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getReadiness() {
type MarketStatus = { market_type: string; label?: string; match_count?: number; bookmaker_count?: number; odds_rows?: number; passes_minimum?: boolean };
type ReadinessResponse = { formal_recommendations_allowed?: boolean; headline?: string; market_status?: MarketStatus[]; blocking_reasons?: string[]; required_actions?: string[] };
async function getReadiness(): Promise<ReadinessResponse> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/recommendation-readiness`, { cache: 'no-store' });
if (!response.ok) throw new Error('推薦就緒度 API 暫時無法回應');
return response.json();
return response.json() as Promise<ReadinessResponse>;
}
export default async function RecommendationReadinessPage() {
let data: any = null;
let data: ReadinessResponse | null = 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 : [];
const marketStatus = data?.market_status ?? [];
const blockers = data?.blocking_reasons ?? [];
const 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>
<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>
<section className="grid gap-4 lg:grid-cols-3">{marketStatus.map((item) => <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]"> {item.match_count ?? 0} {item.bookmaker_count ?? 0} </p><p className="mt-1 text-sm text-[#7a5b46]"> {item.odds_rows ?? 0}</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) => <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) => <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

@@ -2,10 +2,21 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getMatches() {
type MatchRow = {
match_id: string;
home_team: string;
away_team: string;
home_score: number | null;
away_score: number | null;
kickoff_utc: string;
status: string;
venue_city?: string | null;
};
async function getMatches(): Promise<MatchRow[]> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/matches`, { cache: 'no-store' });
if (!response.ok) throw new Error('賽程 API 暫時無法回應');
return response.json();
return response.json() as Promise<MatchRow[]>;
}
function taipeiDate(value: string) {
@@ -17,10 +28,10 @@ function taipeiTime(value: string) {
}
export default async function SchedulePage() {
let matches: any[] = [];
let matches: MatchRow[] = [];
let error = '';
try { matches = await getMatches(); } catch (err) { error = err instanceof Error ? err.message : '賽程暫時無法讀取'; }
const grouped = matches.reduce<Record<string, any[]>>((acc, match) => {
const grouped = matches.reduce<Record<string, MatchRow[]>>((acc, match) => {
const key = taipeiDate(match.kickoff_utc);
acc[key] = [...(acc[key] ?? []), match];
return acc;

View File

@@ -2,10 +2,13 @@ import Link from 'next/link';
const ANALYTICS_BACKEND = process.env.ANALYTICS_BACKEND_URL || 'http://127.0.0.1:8000';
async function getSourceHealth() {
type WorkerStatus = { status?: string; run_at?: string; message?: string };
type SourceHealth = { status?: string; odds_rows?: number; matches?: number; finished_matches?: number; venues?: number; high_altitude_venues?: number; stale_unsettled_matches?: number; latest_odds_recorded_at?: string | null; latest_result_synced_at?: string | null; ingestion_status?: WorkerStatus | null; fixtures_status?: WorkerStatus | null; news_status?: WorkerStatus | null };
async function getSourceHealth(): Promise<SourceHealth> {
const response = await fetch(`${ANALYTICS_BACKEND}/analytics/source-health`, { cache: 'no-store' });
if (!response.ok) throw new Error('資料健康 API 暫時無法回應');
return response.json();
return response.json() as Promise<SourceHealth>;
}
function dateText(value?: string | null) {
@@ -14,47 +17,17 @@ function dateText(value?: string | null) {
}
export default async function SourceHealthPage() {
let data: any = null;
let data: SourceHealth | null = 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 時不升級正式推薦'],
];
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>
<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>
<section className="grid gap-4 md:grid-cols-4">{cards.map(([label, value, helper]) => <article key={String(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={String(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>
);