feat: fill missing product pages and analytics proxy
This commit is contained in:
50
platform/web/app/agent-verification/page.tsx
Normal file
50
platform/web/app/agent-verification/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
platform/web/app/api/analytics/[...path]/route.ts
Normal file
58
platform/web/app/api/analytics/[...path]/route.ts
Normal 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');
|
||||
}
|
||||
30
platform/web/app/battle-room/page.tsx
Normal file
30
platform/web/app/battle-room/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
platform/web/app/live-score/page.tsx
Normal file
29
platform/web/app/live-score/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
platform/web/app/market-coverage/page.tsx
Normal file
46
platform/web/app/market-coverage/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
platform/web/app/news/page.tsx
Normal file
34
platform/web/app/news/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
platform/web/app/recommendation-performance/page.tsx
Normal file
40
platform/web/app/recommendation-performance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
platform/web/app/recommendation-readiness/page.tsx
Normal file
45
platform/web/app/recommendation-readiness/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
platform/web/app/schedule/page.tsx
Normal file
36
platform/web/app/schedule/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
platform/web/app/source-health/page.tsx
Normal file
61
platform/web/app/source-health/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user