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 ?
{items.map((item) =>

{item.title}

{item.source ?? '來源待定'}|{timeText(item.publishedAt ?? item.published_at)}

{item.summary ?

{item.summary}

: null}
)}
:

{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}%
)}
+

模型調整方向

+ 看公開收益帳本 +
+ ); +} 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}

+
+ ))} +
+ 回每日作戰室 +
+ ); +}