diff --git a/platform/web/app/agent-verification/page.tsx b/platform/web/app/agent-verification/page.tsx
new file mode 100644
index 0000000..87ddace
--- /dev/null
+++ b/platform/web/app/agent-verification/page.tsx
@@ -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 (
+
+
+ AI 驗證室
+ Codex + Gemini + NemoTron + 量化閘門
+ AI 只能協助復核資料、新聞、盤口與模型理由;是否能成為正式推薦,仍必須通過賠率、勝率、風險上限與賽後校準閘門。
+
+ {error ?
{error}
: null}
+
+ {[
+ ['整體狀態', 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]) => {helper}
{value}
{label}
)}
+
+
+ {checks.map((item: any) => (
+
+ {item.agent}
+ {item.role}
+ {item.status_label}
+ {(item.evidence ?? []).map((line: string) => - ・{line}
)}
+
+ ))}
+
+
看推薦閘門
+
+ );
+}
diff --git a/platform/web/app/api/analytics/[...path]/route.ts b/platform/web/app/api/analytics/[...path]/route.ts
new file mode 100644
index 0000000..6d9bc55
--- /dev/null
+++ b/platform/web/app/api/analytics/[...path]/route.ts
@@ -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');
+}
diff --git a/platform/web/app/battle-room/page.tsx b/platform/web/app/battle-room/page.tsx
new file mode 100644
index 0000000..2330165
--- /dev/null
+++ b/platform/web/app/battle-room/page.tsx
@@ -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 (
+
+
對戰情報室
先挑比賽,再看模型與盤口
每一場賽事都有自己的量化情報頁,包含模型勝率、進球分布、場地條件與盤口快照。不要把不同場次的賠率混在一起比較。
+ {error ?
{error}
: null}
+
{rows.map((match) => {timeText(match.kickoff_utc)}|{match.status}
{match.home_team} vs {match.away_team}
進入後查看此場專屬模型、進球分布、場地與盤口資訊。
)}
+
回投注作戰室
+
+ );
+}
diff --git a/platform/web/app/live-score/page.tsx b/platform/web/app/live-score/page.tsx
new file mode 100644
index 0000000..3cde6c7
--- /dev/null
+++ b/platform/web/app/live-score/page.tsx
@@ -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 (
+
+
即時比分中心
近期賽事、比分與狀態
比分和完賽結果會直接影響推薦校準;若賽果延遲,系統會降低推薦狀態,不硬推下注。
+ {error ?
{error}
: null}
+
{rows.map((match) => {timeText(match.kickoff_utc)}|{match.status}
{match.home_team} vs {match.away_team}
{match.home_score ?? '-'} : {match.away_score ?? '-'}
{match.venue_city ?? '場地待定'}|點擊查看量化情報
)}
+
+ );
+}
diff --git a/platform/web/app/market-coverage/page.tsx b/platform/web/app/market-coverage/page.tsx
new file mode 100644
index 0000000..4635aa1
--- /dev/null
+++ b/platform/web/app/market-coverage/page.tsx
@@ -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 (
+
+
+ 盤口覆蓋檢查
+ 哪些玩法可以正式推薦,哪些只能監控
+ 只有有足夠盤口來源、分線與賠率可比對的玩法,才會升級為正式下注前檢查。來源不足時只列入預掛監控。
+
+ {error ?
{error}
: null}
+
+ {coverage.map((item: any) => (
+
+ {item.label ?? item.market_type}
+ 賽事:{item.match_count} 場
+ 莊家:{item.bookmaker_count} 家
+ 賠率列:{item.odds_rows}
+ {item.passes_formal_minimum ? '可進正式檢查' : '只能監控'}
+
+ ))}
+
+
+ 缺少或不足玩法
+
+ {missing.length ? missing.map((item: any) =>
{item.label}:{item.reason}
) :
目前沒有回報缺口。
}
+
+
+
看推薦就緒度
+
+ );
+}
diff --git a/platform/web/app/news/page.tsx b/platform/web/app/news/page.tsx
new file mode 100644
index 0000000..5cad116
--- /dev/null
+++ b/platform/web/app/news/page.tsx
@@ -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 (
+
+
+ 即時新聞情報
+ 新聞、傷停與外部事件不能假裝即時
+ 只有接入可公開引用的來源才會顯示新聞;沒有授權新聞時,系統會明確說明缺口,不用假新聞或舊新聞充數。
+ {data?.status_label ?? '新聞快照同步中'}
+
+ {error ?
{error}
: null}
+ {items.length ?
:
{data?.message ?? '目前沒有可公開引用的新聞快照。'}
}
+
+ );
+}
diff --git a/platform/web/app/recommendation-performance/page.tsx b/platform/web/app/recommendation-performance/page.tsx
new file mode 100644
index 0000000..3b0c187
--- /dev/null
+++ b/platform/web/app/recommendation-performance/page.tsx
@@ -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 (
+
+
+ 賽後校準室
+ 推薦要跟實際賽果對帳
+ 每場結束後,系統會把推薦玩法與實際結果比對,追蹤命中率、未中原因與下一輪模型調整方向。
+
+ {error ?
{error}
: null}
+
+ {[
+ ['已結算', 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]) => {helper}
{value}
{label}
)}
+
+
{data?.summary ?? '尚無摘要。'}
+
各玩法表現
{buckets.map((item: any) =>
{item.market_type}
結算 {item.settled_count}|命中 {item.hit_count}|命中率 {item.hit_rate_percent}%)}
+
模型調整方向
{actions.map((item: string) => - ・{item}
)}
+
看公開收益帳本
+
+ );
+}
diff --git a/platform/web/app/recommendation-readiness/page.tsx b/platform/web/app/recommendation-readiness/page.tsx
new file mode 100644
index 0000000..c5cc8ab
--- /dev/null
+++ b/platform/web/app/recommendation-readiness/page.tsx
@@ -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 (
+
+
+ 推薦就緒度
+ 現在能不能稱為正式下注推薦?
+ 這裡是推薦閘門。若盤口來源不足或核心玩法缺盤,就只能顯示監控候選,不能用高勝率語氣誤導使用者。
+ {data?.headline ?? '就緒度同步中'}
+
+ {error ?
{error}
: null}
+
+ {marketStatus.map((item: any) => (
+
+ {item.label}
+ 賽事 {item.match_count} 場|莊家 {item.bookmaker_count} 家
+ 最新賠率列 {item.odds_rows}
+ {item.passes_minimum ? '通過最低門檻' : '未通過最低門檻'}
+
+ ))}
+
+
+ 阻擋原因
{blockers.map((item: string) => - ・{item}
)}
+ 下一步
{actions.map((item: string) => - ・{item}
)}
+
+
回每日作戰室
+
+ );
+}
diff --git a/platform/web/app/schedule/page.tsx b/platform/web/app/schedule/page.tsx
new file mode 100644
index 0000000..9317d62
--- /dev/null
+++ b/platform/web/app/schedule/page.tsx
@@ -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>((acc, match) => {
+ const key = taipeiDate(match.kickoff_utc);
+ acc[key] = [...(acc[key] ?? []), match];
+ return acc;
+ }, {});
+
+ return (
+
+
完整賽程表
依台北時間排序的全部賽事
保留世界盃開踢日起所有日期,完賽結果也會持續保留,方便回看推薦與賽果校準。
+ {error ?
{error}
: null}
+ {Object.entries(grouped).map(([date, rows]) =>
{date}|{rows.length} 場
{rows.map((match) =>
{taipeiTime(match.kickoff_utc)}|{match.home_team} vs {match.away_team}
{match.status}|{match.home_score ?? '-'} : {match.away_score ?? '-'}|{match.venue_city ?? '場地待定'}
)}
)}
+
+ );
+}
diff --git a/platform/web/app/source-health/page.tsx b/platform/web/app/source-health/page.tsx
new file mode 100644
index 0000000..6910ab7
--- /dev/null
+++ b/platform/web/app/source-health/page.tsx
@@ -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 (
+
+
+ 資料健康總覽
+ 先確認資料新鮮,再看投注候選
+ 這頁檢查賽程、賽果、盤口、新聞與排程是否正常。只要資料延遲或盤口不足,首頁就只能顯示監控候選,不能包裝成正式高勝率推薦。
+
+ {error ?
{error}
: null}
+
+ {cards.map(([label, value, helper]) => (
+
+ {helper}
+ {value}
+ {label}
+
+ ))}
+
+
+ {[
+ ['賠率同步', 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]) => (
+
+ {title}
+ {status || 'unknown'}
+ {detail}
+
+ ))}
+
+
回每日作戰室
+
+ );
+}