883 lines
47 KiB
TypeScript
883 lines
47 KiB
TypeScript
'use client';
|
||
|
||
import Link from 'next/link';
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { formatToTaipeiTime } from '@/lib/timezone';
|
||
import {
|
||
getAllMatches,
|
||
getDailyCard,
|
||
getDailyCardCalendar,
|
||
getSourceHealth,
|
||
type DailyCardCalendarDate,
|
||
type DailyCardItem,
|
||
type DailyCardResponse,
|
||
type MatchListItem,
|
||
type SourceHealthResponse,
|
||
} from '@/lib/analytics-api';
|
||
|
||
type NewsItem = {
|
||
title: string;
|
||
url: string;
|
||
source: string;
|
||
publishedAt: string;
|
||
};
|
||
|
||
type LoadErrors = {
|
||
daily?: string;
|
||
matches?: string;
|
||
news?: string;
|
||
source?: string;
|
||
};
|
||
|
||
type GateState = {
|
||
label: string;
|
||
title: string;
|
||
detail: string;
|
||
tone: 'ready' | 'watch' | 'blocked';
|
||
primaryAction: string;
|
||
};
|
||
|
||
const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist';
|
||
const TOURNAMENT_START_DATE = '2026-06-11';
|
||
|
||
const moduleLinks = [
|
||
{ href: '/daily-card', label: '每日作戰室', desc: '單關、串關、同場串關與賠率監控' },
|
||
{ href: '/live-score', label: '即時比分中心', desc: '日期切換、即時比分、完賽結果與場地資訊' },
|
||
{ href: '/schedule', label: '完整賽程表', desc: '依台北時間查看下一場與已完賽結果' },
|
||
{ href: '/battle-room', label: '對戰情報室', desc: '模型勝率、進球分布、環境條件與盤口快照' },
|
||
{ href: '/source-health', label: '資料健康總覽', desc: '檢查賽程、比分、新聞與盤口是否即時更新' },
|
||
{ href: '/recommendation-performance', label: '賽後校準室', desc: '逐玩法回看命中率並修正模型權重' },
|
||
{ href: '/agent-verification', label: 'AI 驗證室', desc: 'Gemini 費用上限、NemoTron 復核與量化閘門' },
|
||
{ href: '/news', label: '即時新聞情報', desc: '新聞、傷停與外部事件監控' },
|
||
];
|
||
|
||
function pickKey(item: DailyCardItem): string {
|
||
const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim();
|
||
return [normalize(item.match_id), normalize(item.market_type), normalize(item.selection)].join('|');
|
||
}
|
||
|
||
function formatTwd(value: number): string {
|
||
return new Intl.NumberFormat('zh-TW', {
|
||
style: 'currency',
|
||
currency: 'TWD',
|
||
maximumFractionDigits: 0,
|
||
}).format(value);
|
||
}
|
||
|
||
function stakeAmountTwd(item: DailyCardItem): number {
|
||
return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000));
|
||
}
|
||
|
||
function itemsFromCard(card: DailyCardResponse | null): DailyCardItem[] {
|
||
if (!card) return [];
|
||
return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles];
|
||
}
|
||
|
||
function matchDate(match: MatchListItem): string {
|
||
return formatToTaipeiTime(match.kickoff_utc, 'yyyy-MM-dd');
|
||
}
|
||
|
||
function isConditionalPick(item: DailyCardItem): boolean {
|
||
return (
|
||
item.has_market_odds === false ||
|
||
item.selection.includes('預掛條件') ||
|
||
item.selection.includes('參考盤監控') ||
|
||
item.rationale.includes('尚未取得可用即時盤口') ||
|
||
item.rationale.includes('尚未取得完整實盤賠率') ||
|
||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
|
||
);
|
||
}
|
||
|
||
function recommendationText(item: DailyCardItem): string {
|
||
const conditional = isConditionalPick(item);
|
||
if (item.recommendation === 'SAFE_SINGLE') return conditional ? '觀察單關' : '核心單關';
|
||
if (item.recommendation === 'HIGH_RISK_SINGLE') return conditional ? '高賠觀察' : '小注高賠';
|
||
if (item.recommendation === 'SAFE_PARLAY') return conditional ? '觀察串關' : '跨場串關';
|
||
if (item.recommendation === 'SGP_LOTTERY') return conditional ? '同場觀察' : '同場小注';
|
||
return '研究候選';
|
||
}
|
||
|
||
function nextStepText(item: DailyCardItem): string {
|
||
if (isConditionalPick(item) || !item.has_market_odds) {
|
||
return '現在不要下注,先加入賠率監控。等平台開盤後,只有實際賠率達到卡片門檻才考慮。';
|
||
}
|
||
if (item.odds_source_kind === 'reference_market') {
|
||
return '已有台灣運彩參考盤,可先做下注前檢查;但它不是多來源正式盤,下單前仍要確認平台最新盤口。';
|
||
}
|
||
return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。';
|
||
}
|
||
|
||
function buildGateState(
|
||
sourceHealth: SourceHealthResponse | null,
|
||
errors: LoadErrors,
|
||
liveCount: number,
|
||
watchCount: number,
|
||
): GateState {
|
||
if (errors.source) {
|
||
return {
|
||
label: '資料源異常',
|
||
title: watchCount > 0 ? '正式下注先保留,預掛候選持續監控' : '資料源恢復前只保留研究觀察',
|
||
detail: watchCount > 0
|
||
? `資料源健康狀態暫時無法讀取,但仍保留 ${watchCount} 組預掛候選。這些不是立即下注單,必須等資料源恢復並達到賠率門檻。`
|
||
: '資料源健康狀態暫時無法讀取,首頁只保留資訊瀏覽,不把不完整資料包裝成高勝率下注。',
|
||
tone: watchCount > 0 ? 'watch' : 'blocked',
|
||
primaryAction: watchCount > 0 ? '查看預掛候選' : '檢查資料源',
|
||
};
|
||
}
|
||
|
||
if (!sourceHealth) {
|
||
return {
|
||
label: '同步中',
|
||
title: '正在讀取最新資料',
|
||
detail: '系統正在同步賽程、賽果、新聞與盤口狀態,完成後才會顯示候選。',
|
||
tone: 'watch',
|
||
primaryAction: '稍候刷新',
|
||
};
|
||
}
|
||
|
||
if ((sourceHealth.stale_unsettled_matches ?? 0) > 0) {
|
||
return {
|
||
label: '賽果延遲',
|
||
title: watchCount > 0 ? '正式下注暫停,預掛觀察持續更新' : '賽果追上前不硬推下注',
|
||
detail: watchCount > 0
|
||
? `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果,所以不升級成正式下注推薦;但 ${watchCount} 組日期盤候選仍會持續監控賠率門檻。`
|
||
: `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果。資料追上前,不會硬推下注,只保留資訊與條件監控。`,
|
||
tone: watchCount > 0 ? 'watch' : 'blocked',
|
||
primaryAction: watchCount > 0 ? '查看預掛候選' : '查看賽事中心',
|
||
};
|
||
}
|
||
|
||
if (sourceHealth.odds_coverage_status === 'full_market' && liveCount > 0) {
|
||
return {
|
||
label: '可檢查',
|
||
title: '已有實盤候選,可做下注前檢查',
|
||
detail: `目前有 ${liveCount} 組候選具備可比對盤口。仍需確認賠率、分線、傷停與注碼上限後才下單。`,
|
||
tone: 'ready',
|
||
primaryAction: '檢查推薦',
|
||
};
|
||
}
|
||
|
||
if (watchCount > 0) {
|
||
return {
|
||
label: '預掛觀察',
|
||
title: '日期盤候選持續推薦,先等賠率達標',
|
||
detail: `已找到 ${watchCount} 組模型候選,但正式盤口不足或仍是比分備援來源。這些會持續推薦為預掛觀察,不是叫你立刻下注;等平台賠率達到門檻再進下注前檢查。`,
|
||
tone: 'watch',
|
||
primaryAction: '查看預掛候選',
|
||
};
|
||
}
|
||
|
||
return {
|
||
label: '暫無候選',
|
||
title: '沒有達標候選就不硬推',
|
||
detail: '目前沒有同時通過勝率、最低賠率、資料新鮮度與風險上限的選項。保留資金比硬下注更專業。',
|
||
tone: 'blocked',
|
||
primaryAction: '等待刷新',
|
||
};
|
||
}
|
||
|
||
function gateClass(tone: GateState['tone']): string {
|
||
if (tone === 'ready') return 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]';
|
||
if (tone === 'blocked') return 'border-[#e7a49a] bg-[#fff0e8] text-[#b83822]';
|
||
return 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]';
|
||
}
|
||
|
||
function weekdayLabel(date: string): string {
|
||
return new Intl.DateTimeFormat('zh-TW', {
|
||
timeZone: 'Asia/Taipei',
|
||
weekday: 'short',
|
||
}).format(new Date(`${date}T00:00:00+08:00`));
|
||
}
|
||
|
||
function recommendationDateLabel(date: string, today: string, tomorrow: string): string {
|
||
if (date === today) return `今天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
|
||
if (date === tomorrow) return `明天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
|
||
return `${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
|
||
}
|
||
|
||
function recommendationDateState(date: string, today: string): string {
|
||
if (date < today) return '已完賽回看';
|
||
if (date === today) return '今日優先';
|
||
return '預售分析';
|
||
}
|
||
|
||
export default function Home() {
|
||
const [dateWindow] = useState(() => {
|
||
const now = new Date();
|
||
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||
return {
|
||
today: formatToTaipeiTime(now.toISOString(), 'yyyy-MM-dd'),
|
||
tomorrow: formatToTaipeiTime(tomorrow.toISOString(), 'yyyy-MM-dd'),
|
||
};
|
||
});
|
||
const [data, setData] = useState<DailyCardResponse | null>(null);
|
||
const [tomorrowData, setTomorrowData] = useState<DailyCardResponse | null>(null);
|
||
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
|
||
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
|
||
const [selectedRecommendationDate, setSelectedRecommendationDate] = useState('');
|
||
const [matches, setMatches] = useState<MatchListItem[]>([]);
|
||
const [news, setNews] = useState<NewsItem[]>([]);
|
||
const [sourceHealth, setSourceHealth] = useState<SourceHealthResponse | null>(null);
|
||
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
|
||
const [feedbackMessage, setFeedbackMessage] = useState('');
|
||
const [loading, setLoading] = useState(true);
|
||
const [errors, setErrors] = useState<LoadErrors>({});
|
||
const [loadedAt, setLoadedAt] = useState('');
|
||
|
||
useEffect(() => {
|
||
try {
|
||
const raw = window.localStorage.getItem(WATCHLIST_KEY);
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) {
|
||
setWatchlist(parsed.filter(Boolean).slice(0, 20));
|
||
}
|
||
} catch {
|
||
window.localStorage.removeItem(WATCHLIST_KEY);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
|
||
async function load() {
|
||
setLoading(true);
|
||
setErrors({});
|
||
|
||
const [dailyResult, tomorrowResult, matchesResult, newsResult, sourceResult, calendarResult] = await Promise.allSettled([
|
||
getDailyCard(dateWindow.today),
|
||
getDailyCard(dateWindow.tomorrow),
|
||
getAllMatches(),
|
||
fetch('/api/news', { cache: 'no-store' }).then(async (response) => {
|
||
if (!response.ok) throw new Error(`news status ${response.status}`);
|
||
return response.json() as Promise<{ items?: NewsItem[] }>;
|
||
}),
|
||
getSourceHealth(),
|
||
getDailyCardCalendar(TOURNAMENT_START_DATE),
|
||
]);
|
||
|
||
if (!mounted) return;
|
||
|
||
const nextErrors: LoadErrors = {};
|
||
const matchPayload = matchesResult.status === 'fulfilled' ? matchesResult.value : [];
|
||
const calendarPayload = calendarResult.status === 'fulfilled' ? calendarResult.value.dates : [];
|
||
const calendarDateList = calendarPayload.map((item) => item.date).sort((a, b) => a.localeCompare(b));
|
||
const pinnedDates = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarDateList.includes(date));
|
||
const pinnedSet = new Set(pinnedDates);
|
||
const nextCards: Record<string, DailyCardResponse> = {};
|
||
const requestDates = [...pinnedDates, ...calendarDateList.filter((date) => !pinnedSet.has(date))];
|
||
const preferredDate = calendarPayload.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date
|
||
?? calendarPayload.find((item) => item.date >= dateWindow.today && item.recommendation_count > 0)?.date
|
||
?? pinnedDates[0]
|
||
?? requestDates.find((date) => date >= dateWindow.today)
|
||
?? requestDates[0]
|
||
?? dateWindow.today;
|
||
|
||
if (!mounted) return;
|
||
|
||
if (dailyResult.status === 'fulfilled') {
|
||
setData(dailyResult.value);
|
||
nextCards[dateWindow.today] = dailyResult.value;
|
||
}
|
||
else nextErrors.daily = '每日推薦 API 尚未回應';
|
||
|
||
if (tomorrowResult.status === 'fulfilled') {
|
||
setTomorrowData(tomorrowResult.value);
|
||
nextCards[dateWindow.tomorrow] = tomorrowResult.value;
|
||
}
|
||
else nextErrors.daily = nextErrors.daily ?? '明日推薦 API 尚未回應';
|
||
|
||
if (matchesResult.status === 'fulfilled') setMatches(matchesResult.value);
|
||
else nextErrors.matches = '賽事中心 API 尚未回應';
|
||
|
||
if (newsResult.status === 'fulfilled') setNews(newsResult.value.items ?? []);
|
||
else nextErrors.news = '新聞 RSS 暫時無法更新';
|
||
|
||
if (sourceResult.status === 'fulfilled') setSourceHealth(sourceResult.value);
|
||
else nextErrors.source = '盤口資料源健康狀態暫時無法讀取';
|
||
|
||
if (calendarResult.status === 'fulfilled') setCalendarDates(calendarPayload);
|
||
else nextErrors.daily = nextErrors.daily ?? '日期推薦摘要 API 尚未回應';
|
||
|
||
setDailyCards(nextCards);
|
||
setSelectedRecommendationDate((current) => {
|
||
if (requestDates.includes(current)) return current;
|
||
return preferredDate;
|
||
});
|
||
setErrors(nextErrors);
|
||
setLoadedAt(formatToTaipeiTime(new Date().toISOString()));
|
||
setLoading(false);
|
||
}
|
||
|
||
load().catch((error) => {
|
||
if (mounted) {
|
||
setErrors({ daily: error instanceof Error ? error.message : '無法取得最新量化資料' });
|
||
setLoading(false);
|
||
}
|
||
});
|
||
const interval = window.setInterval(() => {
|
||
load().catch(() => undefined);
|
||
}, 60_000);
|
||
|
||
return () => {
|
||
mounted = false;
|
||
window.clearInterval(interval);
|
||
};
|
||
}, [dateWindow.today, dateWindow.tomorrow]);
|
||
|
||
useEffect(() => {
|
||
if (!selectedRecommendationDate || dailyCards[selectedRecommendationDate]) return undefined;
|
||
|
||
let mounted = true;
|
||
getDailyCard(selectedRecommendationDate)
|
||
.then((card) => {
|
||
if (!mounted) return;
|
||
setDailyCards((current) => ({ ...current, [selectedRecommendationDate]: card }));
|
||
})
|
||
.catch(() => {
|
||
if (!mounted) return;
|
||
setErrors((current) => ({ ...current, daily: '此日期推薦卡片暫時無法載入' }));
|
||
});
|
||
|
||
return () => {
|
||
mounted = false;
|
||
};
|
||
}, [dailyCards, selectedRecommendationDate]);
|
||
|
||
const matchesByDate = useMemo(() => {
|
||
const grouped = new Map<string, MatchListItem[]>();
|
||
matches.forEach((match) => {
|
||
const date = matchDate(match);
|
||
if (date < TOURNAMENT_START_DATE) return;
|
||
grouped.set(date, [...(grouped.get(date) ?? []), match]);
|
||
});
|
||
return grouped;
|
||
}, [matches]);
|
||
|
||
const chronologicalDates = useMemo(() => {
|
||
return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
|
||
}, [calendarDates]);
|
||
|
||
const calendarByDate = useMemo(() => {
|
||
return new Map(calendarDates.map((item) => [item.date, item]));
|
||
}, [calendarDates]);
|
||
|
||
const recommendationDateOptions = useMemo(() => {
|
||
const pinned = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarByDate.has(date));
|
||
const pinnedSet = new Set(pinned);
|
||
return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))];
|
||
}, [calendarByDate, chronologicalDates, dateWindow.today, dateWindow.tomorrow]);
|
||
|
||
const activeRecommendationDate = selectedRecommendationDate || recommendationDateOptions[0] || dateWindow.today;
|
||
const selectedCard = dailyCards[activeRecommendationDate]
|
||
?? (activeRecommendationDate === dateWindow.tomorrow ? tomorrowData : null)
|
||
?? (activeRecommendationDate === dateWindow.today ? data : null);
|
||
const recommendationItems = useMemo(() => itemsFromCard(selectedCard), [selectedCard]);
|
||
const selectedDateSummary = calendarByDate.get(activeRecommendationDate);
|
||
const selectedMatchCount = selectedDateSummary?.match_count ?? matchesByDate.get(activeRecommendationDate)?.length ?? selectedCard?.matched_matches ?? 0;
|
||
const dateSummaries = useMemo(() => {
|
||
return recommendationDateOptions.map((date) => {
|
||
const card = dailyCards[date]
|
||
?? (date === dateWindow.today ? data : null)
|
||
?? (date === dateWindow.tomorrow ? tomorrowData : null);
|
||
const items = itemsFromCard(card);
|
||
const summary = calendarByDate.get(date);
|
||
return {
|
||
date,
|
||
matchCount: summary?.match_count ?? matchesByDate.get(date)?.length ?? card?.matched_matches ?? 0,
|
||
recommendationCount: summary?.recommendation_count ?? items.length,
|
||
liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length,
|
||
};
|
||
});
|
||
}, [calendarByDate, dailyCards, data, dateWindow.today, dateWindow.tomorrow, matchesByDate, recommendationDateOptions, tomorrowData]);
|
||
|
||
const topPicks = useMemo<DailyCardItem[]>(() => {
|
||
const rank = (items: DailyCardItem[]) =>
|
||
[...items].sort((a, b) => {
|
||
const aLiveBonus = a.has_market_odds ? 18 : 0;
|
||
const bLiveBonus = b.has_market_odds ? 18 : 0;
|
||
const aScore = aLiveBonus + (a.confidence_score ?? a.win_prob) + (a.ev_percent * 0.22) - (a.stake_units * 0.8);
|
||
const bScore = bLiveBonus + (b.confidence_score ?? b.win_prob) + (b.ev_percent * 0.22) - (b.stake_units * 0.8);
|
||
return bScore - aScore;
|
||
});
|
||
|
||
const singles = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_SINGLE'));
|
||
const parlays = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_PARLAY'));
|
||
const sameGame = rank(recommendationItems.filter((item) => item.recommendation === 'SGP_LOTTERY'));
|
||
const highRisk = rank(recommendationItems.filter((item) => item.recommendation === 'HIGH_RISK_SINGLE'));
|
||
const balanced = [...singles.slice(0, 3), ...parlays.slice(0, 2), ...sameGame.slice(0, 1), ...highRisk.slice(0, 2)];
|
||
const used = new Set(balanced.map(pickKey));
|
||
const remaining = rank(recommendationItems).filter((item) => !used.has(pickKey(item)));
|
||
return [...balanced, ...remaining].slice(0, 10);
|
||
}, [recommendationItems]);
|
||
|
||
const categorySummary = useMemo(() => {
|
||
const count = (type: DailyCardItem['recommendation']) => recommendationItems.filter((item) => item.recommendation === type).length;
|
||
return [
|
||
{ label: '單關', value: count('SAFE_SINGLE') },
|
||
{ label: '跨場串關', value: count('SAFE_PARLAY') },
|
||
{ label: '同場串關', value: count('SGP_LOTTERY') },
|
||
{ label: '小注高賠', value: count('HIGH_RISK_SINGLE') },
|
||
];
|
||
}, [recommendationItems]);
|
||
|
||
const nextMatches = useMemo(() => {
|
||
const now = Date.now();
|
||
return matches
|
||
.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 1000 * 60 * 120)
|
||
.sort((a, b) => new Date(a.kickoff_utc).getTime() - new Date(b.kickoff_utc).getTime())
|
||
.slice(0, 5);
|
||
}, [matches]);
|
||
|
||
const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]);
|
||
const liveRecommendationCount = recommendationItems.filter((item) => item.has_market_odds).length;
|
||
const watchOnlyRecommendationCount = recommendationItems.length - liveRecommendationCount;
|
||
const gate = buildGateState(sourceHealth, errors, liveRecommendationCount, watchOnlyRecommendationCount);
|
||
|
||
const latestOddsLabel = sourceHealth?.latest_odds_recorded_at ? formatToTaipeiTime(sourceHealth.latest_odds_recorded_at) : '尚無盤口時間';
|
||
const latestResultLabel = sourceHealth?.latest_result_synced_at ? formatToTaipeiTime(sourceHealth.latest_result_synced_at) : '尚無賽果時間';
|
||
const latestNewsLabel = sourceHealth?.news_status?.run_at ? formatToTaipeiTime(sourceHealth.news_status.run_at) : '尚無新聞排程';
|
||
const latestFixturesLabel = sourceHealth?.fixtures_status?.run_at ? formatToTaipeiTime(sourceHealth.fixtures_status.run_at) : '尚無賽程排程';
|
||
const oddsCoverageLabel = sourceHealth?.odds_coverage_status === 'full_market'
|
||
? '完整實盤'
|
||
: sourceHealth?.odds_coverage_status === 'reference_market'
|
||
? '台灣運彩參考盤'
|
||
: sourceHealth?.odds_coverage_status === 'limited_scoreboard_fallback'
|
||
? '比分備援,盤口不足'
|
||
: sourceHealth?.odds_coverage_status === 'no_upcoming_market'
|
||
? '未來賽事無實盤'
|
||
: '資料不足';
|
||
const activeDateLabel = recommendationDateLabel(activeRecommendationDate, dateWindow.today, dateWindow.tomorrow);
|
||
const activeDateState = recommendationDateState(activeRecommendationDate, dateWindow.today);
|
||
const activeDateRecommendationCount = selectedDateSummary?.recommendation_count ?? recommendationItems.length;
|
||
const activeDateLiveCount = selectedDateSummary?.live_count ?? liveRecommendationCount;
|
||
const activeDateWatchCount = Math.max(activeDateRecommendationCount - activeDateLiveCount, 0);
|
||
const activeDateSummaryText = `${activeDateLabel}|${selectedMatchCount} 場比賽、${activeDateRecommendationCount} 筆候選,其中 ${activeDateLiveCount} 筆可做下注前檢查、${activeDateWatchCount} 筆先監控賠率。`;
|
||
|
||
const healthItems = [
|
||
{
|
||
label: '資料可用性',
|
||
ok: Boolean(sourceHealth) && (sourceHealth?.stale_unsettled_matches ?? 0) === 0 && !errors.source,
|
||
detail: errors.source ?? `${oddsCoverageLabel};賽果 ${latestResultLabel},逾時未更新 ${sourceHealth?.stale_unsettled_matches ?? 0} 場`,
|
||
},
|
||
{
|
||
label: '推薦狀態',
|
||
ok: liveRecommendationCount > 0,
|
||
detail: `${liveRecommendationCount} 組可檢查、${watchOnlyRecommendationCount} 組只監控;目前日期掃描 ${selectedMatchCount} 場`,
|
||
},
|
||
{
|
||
label: '賽程與新聞',
|
||
ok: matches.length > 0 && news.length > 0 && !errors.matches && !errors.news,
|
||
detail: `賽程 ${matches.length} 場,排程 ${latestFixturesLabel};新聞 ${news.length} 則,排程 ${latestNewsLabel}`,
|
||
},
|
||
];
|
||
|
||
function handleTrack(item: DailyCardItem) {
|
||
const key = pickKey(item);
|
||
if (watchlistKeys.has(key)) {
|
||
setFeedbackMessage(`已在清單中:${item.match_label} | ${item.selection}`);
|
||
return;
|
||
}
|
||
const next = [item, ...watchlist].slice(0, 20);
|
||
setWatchlist(next);
|
||
setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}:${item.match_label} | ${item.selection}`);
|
||
try {
|
||
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
|
||
} catch {
|
||
setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} | ${item.selection}`);
|
||
}
|
||
}
|
||
|
||
function handleRemoveTracked(item: DailyCardItem) {
|
||
const key = pickKey(item);
|
||
const next = watchlist.filter((tracked) => pickKey(tracked) !== key);
|
||
setWatchlist(next);
|
||
setFeedbackMessage(`已移除監控:${item.match_label} | ${item.selection}`);
|
||
try {
|
||
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
|
||
} catch {
|
||
setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<p className="dot-matrix text-xs font-semibold text-[#b83822]">首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}</p>
|
||
<h2 className="mt-2 text-3xl font-black text-[#3f2f25] md:text-5xl">下一批可檢查投注候選</h2>
|
||
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
|
||
一打開首頁先看這裡:系統會先看今天,若今天已無可下注賽事,就自動切到明天有資料的場次。若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。
|
||
</p>
|
||
<p className="mt-3 rounded-2xl border border-[#eadcb9] bg-white/75 px-4 py-3 text-sm font-bold text-[#5f4330]">
|
||
目前主顯示日期:<span className="text-[#b83822]">{activeRecommendationDate}</span>
|
||
。當日賽事 {selectedMatchCount} 場,推薦 {recommendationItems.length} 組。
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2 text-xs font-bold">
|
||
<span className="rounded-full bg-[#e9f8ef] px-3 py-1 text-[#167a47]">可檢查 {liveRecommendationCount} 組</span>
|
||
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-[#8a6400]">預掛監控 {watchOnlyRecommendationCount} 組</span>
|
||
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-[#b83822]">60 秒刷新</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
|
||
{dateSummaries.map((item) => {
|
||
const pinnedLabel = item.date === dateWindow.today ? '今天' : item.date === dateWindow.tomorrow ? '明天' : '';
|
||
return (
|
||
<button
|
||
key={item.date}
|
||
type="button"
|
||
onClick={() => setSelectedRecommendationDate(item.date)}
|
||
className={`rounded-full border px-3 py-1.5 text-left transition ${
|
||
activeRecommendationDate === item.date
|
||
? 'border-[#7d2a15] bg-[#7d2a15] text-white'
|
||
: 'border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822]'
|
||
}`}
|
||
>
|
||
{item.date}{pinnedLabel ? ` ${pinnedLabel}` : ''}|{item.matchCount} 場|{item.recommendationCount} 推
|
||
{item.liveCount > 0 ? `|${item.liveCount} 可檢查` : ''}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
|
||
{categorySummary.map((item) => (
|
||
<span key={item.label} className="rounded-full border border-[#d8b58c] bg-white/75 px-3 py-1 text-[#5f4330]">
|
||
{item.label} {item.value} 組
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{feedbackMessage ? (
|
||
<p role="status" className="mt-4 rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
|
||
{feedbackMessage}
|
||
</p>
|
||
) : null}
|
||
|
||
<section className="mt-5 rounded-3xl border border-[#eadcb9] bg-white/70 p-4">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||
<div>
|
||
<p className="dot-matrix text-xs font-bold text-[#b83822]">我的賠率監控清單</p>
|
||
<h3 className="mt-1 text-xl font-black text-[#3f2f25]">
|
||
{watchlist.length ? `已追蹤 ${watchlist.length} 組候選` : '還沒有加入候選'}
|
||
</h3>
|
||
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
|
||
加入後不是立刻下注,而是集中檢查來源、最低賠率、玩法與注碼上限。台灣盤參考價與模型推算門檻會分開標示。
|
||
</p>
|
||
</div>
|
||
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-[#fff8e6] px-4 py-2 text-sm font-black text-[#7d2a15] transition hover:border-[#b83822]">
|
||
打開完整檢查清單
|
||
</Link>
|
||
</div>
|
||
{watchlist.length ? (
|
||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||
{watchlist.slice(0, 4).map((item) => {
|
||
const conditional = isConditionalPick(item) || !item.has_market_odds;
|
||
return (
|
||
<article key={`watch-${pickKey(item)}`} className="rounded-2xl border border-[#e7c89b] bg-[#fff8e6]/90 p-4">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className={`rounded-full px-2 py-1 text-[11px] font-black ${conditional ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'}`}>
|
||
{conditional ? '先監控' : '可檢查'}
|
||
</span>
|
||
{item.odds_source_label ? (
|
||
<span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span>
|
||
) : null}
|
||
</div>
|
||
<p className="mt-2 text-sm font-black text-[#3f2f25]">{item.match_label}</p>
|
||
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.market_type}|{item.selection}</p>
|
||
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-[#7a5b46]">
|
||
<p>門檻<br /><b className="text-[#b83822]">{item.target_odds.toFixed(2)}</b></p>
|
||
<p>信心<br /><b className="text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : item.win_prob.toFixed(1)}</b></p>
|
||
<p>上限<br /><b className="text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</b></p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="mt-3 rounded-full border border-[#d8b58c] bg-white px-3 py-1.5 text-xs font-black text-[#7d2a15] transition hover:border-[#b83822]"
|
||
onClick={() => handleRemoveTracked(item)}
|
||
>
|
||
移除這筆
|
||
</button>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-[#fff8e6]/80 p-4 text-sm leading-6 text-[#7a5b46]">
|
||
看到候選卡片時,先按「加入賠率監控」或「加入下注前檢查」。清單會保留在此瀏覽器中,方便你逐筆核對,不用重新找。
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
{loading ? (
|
||
<p className="mt-6 dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">正在同步日期賽事與多玩法推薦...</p>
|
||
) : topPicks.length ? (
|
||
<div className="mt-6 grid gap-4 lg:grid-cols-3">
|
||
{topPicks.slice(0, 9).map((item) => {
|
||
const conditional = isConditionalPick(item) || !item.has_market_odds;
|
||
const inList = watchlistKeys.has(pickKey(item));
|
||
return (
|
||
<article key={`hero-${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-white/80 p-5 shadow-[0_14px_36px_rgba(125,42,21,0.10)]">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
|
||
<span className={`rounded-full border px-2 py-1 text-[11px] font-black ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
|
||
{conditional ? '預掛觀察' : '下注前檢查'}
|
||
</span>
|
||
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-[#e9f8ef] px-2 py-1 text-[11px] font-black text-[#167a47]">信心分數 {item.confidence_score.toFixed(1)}</span> : null}
|
||
{item.odds_source_label ? <span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span> : null}
|
||
</div>
|
||
<h3 className="mt-3 text-lg font-black text-[#3f2f25]">{item.match_label}</h3>
|
||
<p className="mt-1 text-xs font-bold text-[#7a5b46]">台北日期:{activeDateLabel}</p>
|
||
<p className="mt-2 text-sm font-bold text-[#b83822]">{item.market_type}|{item.selection}</p>
|
||
<p className="mt-2 text-xs leading-5 text-[#8a6b58]">同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。</p>
|
||
<div className="mt-4 grid gap-2 text-sm text-[#5f4330]">
|
||
<p>最低可接受賠率:<span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||
<p>模型勝率:<span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||
<p>參考上限:<span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||
</div>
|
||
<p className="mt-3 rounded-2xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#6f4f3c]">
|
||
{nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。
|
||
</p>
|
||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||
<button
|
||
type="button"
|
||
aria-pressed={inList}
|
||
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
|
||
onClick={() => handleTrack(item)}
|
||
>
|
||
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
|
||
</button>
|
||
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
|
||
完整分析
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="mt-6 rounded-3xl border border-dashed border-[#d8b58c] bg-white/65 p-5">
|
||
<p className="text-sm leading-7 text-[#6f4f3c]">目前沒有通過模型勝率、最低賠率、資料新鮮度與倉位上限的候選。專業推薦不是硬湊數量,沒有優勢就保留資金,等下一輪資料刷新。</p>
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="relative overflow-hidden rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
|
||
<div className="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_center,rgba(209,67,45,0.16),transparent_55%)]" />
|
||
<div className="relative grid gap-8 lg:grid-cols-[1.08fr_0.92fr]">
|
||
<div>
|
||
<p className="dot-matrix text-xs font-semibold text-[#b83822]">台北即時投注研究台 / {activeDateState} / {activeRecommendationDate}</p>
|
||
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight text-[#3f2f25] md:text-6xl">
|
||
世界盃下注推薦主控台
|
||
</h2>
|
||
<p className="mt-4 max-w-2xl text-base leading-7 text-[#6f4f3c]">
|
||
首頁先顯示可研究的投注候選,再判斷能不能進場。{activeDateSummaryText} 資料不足時只開監控,不用漂亮空殼假裝高勝率。
|
||
</p>
|
||
<div className={`mt-6 rounded-3xl border p-5 ${gateClass(gate.tone)}`}>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-black">{gate.label}</span>
|
||
<span className="text-xs font-semibold">{loadedAt || '同步中'}</span>
|
||
</div>
|
||
<h3 className="mt-3 text-2xl font-black">{gate.title}</h3>
|
||
<p className="mt-2 text-sm leading-6">{gate.detail}</p>
|
||
<div className="mt-4 flex flex-wrap gap-2 text-xs font-bold">
|
||
<span className="rounded-full bg-white/65 px-3 py-1">正式盤口:{sourceHealth?.odds_coverage_status === 'full_market' ? '已具備' : '不足'}</span>
|
||
<span className="rounded-full bg-white/65 px-3 py-1">逾時賽果:{sourceHealth?.stale_unsettled_matches ?? 0} 場</span>
|
||
<span className="rounded-full bg-white/65 px-3 py-1">目前日期:{activeDateLabel}</span>
|
||
<span className="rounded-full bg-white/65 px-3 py-1">監控清單:{watchlist.length} 筆</span>
|
||
</div>
|
||
</div>
|
||
<div className="mt-6 flex flex-wrap gap-3">
|
||
<Link href="/daily-card" className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]">
|
||
{gate.primaryAction}
|
||
</Link>
|
||
<Link href="/market-coverage" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
|
||
檢查盤口覆蓋
|
||
</Link>
|
||
<Link href="/recommendation-readiness" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#1a9a57] hover:text-[#1a9a57]">
|
||
推薦就緒度
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-3xl border border-[#e7c89b] bg-white/75 p-5 shadow-inner">
|
||
<div className="flex items-center justify-between">
|
||
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]">專業檢查順序</p>
|
||
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7a5b46]">60 秒刷新</span>
|
||
</div>
|
||
<div className="mt-4 grid gap-3">
|
||
{[
|
||
['1', '先看狀態', '若是只監控或資料延遲,就不要直接下注。'],
|
||
['2', '確認玩法', '單關、串關、同場串關要分開看,不混成同一種推薦。'],
|
||
['3', '比對賠率', '平台賠率低於最低門檻就跳過,不能硬追。'],
|
||
['4', '控制注碼', '只用卡片建議單位,尤其高賠與同場串關要小注。'],
|
||
].map(([step, title, detail]) => (
|
||
<article key={step} className="rounded-2xl border border-[#eadcb9] bg-[#fff8e6]/80 p-4">
|
||
<div className="flex gap-3">
|
||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{step}</span>
|
||
<div>
|
||
<p className="font-bold text-[#5f4031]">{title}</p>
|
||
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{detail}</p>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid gap-4 md:grid-cols-4">
|
||
{[
|
||
{ label: '可檢查候選', value: selectedCard ? liveRecommendationCount : '-', helper: '同日實盤資料' },
|
||
{ label: '監控候選', value: selectedCard ? watchOnlyRecommendationCount : '-', helper: '同日等賠率' },
|
||
{ label: '串關玩法', value: selectedCard ? recommendationItems.filter((item) => ['SAFE_PARLAY', 'SGP_LOTTERY'].includes(item.recommendation)).length : '-', helper: '跨場/同場' },
|
||
{ label: '近期賽事', value: nextMatches.length || '-', helper: '台北時間排序' },
|
||
].map((item) => (
|
||
<article key={item.label} className="panel-glow rounded-2xl p-5">
|
||
<p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{item.helper}</p>
|
||
<p className="mt-3 text-3xl font-black text-[#7d2a15]">{item.value}</p>
|
||
<p className="mt-1 text-sm text-[#6f4f3c]">{item.label}</p>
|
||
</article>
|
||
))}
|
||
</section>
|
||
|
||
<section className="grid gap-6 lg:grid-cols-[1.38fr_0.62fr]">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<p className="dot-matrix text-xs text-[#b83822]">依日期優先檢查 / {activeDateState}</p>
|
||
<h2 className="text-2xl font-black text-[#3f2f25]">{activeDateLabel} 多玩法候選清單</h2>
|
||
<p className="mt-2 text-sm text-[#7a5b46]">{activeDateSummaryText} 詳細下注方式與白話分析請進每日作戰室逐張檢查。</p>
|
||
</div>
|
||
<Link href="/daily-card" className="text-sm font-semibold text-[#b83822] hover:text-[#7d2a15]">看完整作戰室</Link>
|
||
</div>
|
||
|
||
{feedbackMessage ? (
|
||
<p role="status" className="rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
|
||
{feedbackMessage}
|
||
</p>
|
||
) : null}
|
||
|
||
{loading ? (
|
||
<p className="dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">同步市場即時資料中...</p>
|
||
) : topPicks.length ? (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{topPicks.map((item) => {
|
||
const conditional = isConditionalPick(item) || !item.has_market_odds;
|
||
const inList = watchlistKeys.has(pickKey(item));
|
||
return (
|
||
<article key={`${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.10)]">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
|
||
<span className={`rounded-full border px-2 py-1 text-[11px] font-bold ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
|
||
{conditional ? '先監控' : '可檢查'}
|
||
</span>
|
||
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#167a47]">信心分數 {item.confidence_score.toFixed(1)}</span> : null}
|
||
<span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7a5b46]">{activeDateLabel}</span>
|
||
{item.confidence_band ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7d2a15]">{item.confidence_band}</span> : null}
|
||
</div>
|
||
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
|
||
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type} | {item.selection}</p>
|
||
<div className="mt-4 grid gap-2 text-sm text-[#5f4330] md:grid-cols-3">
|
||
<p>模型勝率 <span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
|
||
<p>最低賠率 <span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
|
||
<p>參考上限 <span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
|
||
</div>
|
||
<p className="mt-3 rounded-2xl bg-white/70 p-3 text-sm leading-6 text-[#6f4f3c]">
|
||
{nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。
|
||
</p>
|
||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||
<button
|
||
type="button"
|
||
aria-pressed={inList}
|
||
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
|
||
onClick={() => handleTrack(item)}
|
||
>
|
||
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
|
||
</button>
|
||
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white/70 px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
|
||
看完整分析
|
||
</Link>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="panel-glow rounded-2xl border-dashed p-6">
|
||
<p className="text-sm leading-7 text-[#6f4f3c]">目前沒有通過勝率、期望值、資料新鮮度與注碼上限的候選。沒有優勢就不硬推單,這才是專業紀律。</p>
|
||
<p className="mt-2 text-xs text-[#8a6b58]">{errors.daily ?? selectedCard?.summary ?? '等待下一次資料刷新。'}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<aside className="space-y-4">
|
||
<section className="panel-glow rounded-2xl p-5">
|
||
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]">資料源健康</h3>
|
||
<div className="mt-4 space-y-3">
|
||
{healthItems.map((item) => (
|
||
<article key={item.label} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`status-led ${item.ok ? 'status-led-ok' : 'status-led-warn'}`} />
|
||
<p className="text-sm font-bold text-[#3f2f25]">{item.label}</p>
|
||
</div>
|
||
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-5">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]">近期賽程</h3>
|
||
<Link href="/schedule" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]">完整賽程</Link>
|
||
</div>
|
||
<div className="mt-4 space-y-3">
|
||
{nextMatches.length ? nextMatches.map((match) => (
|
||
<Link key={match.match_id} href={`/matches/${match.match_id}`} className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
|
||
<p className="text-sm font-bold text-[#3f2f25]">{match.home_team} vs {match.away_team}</p>
|
||
<p className="mt-1 text-xs text-[#7a5b46]">{formatToTaipeiTime(match.kickoff_utc, 'MM/dd HH:mm')} | {match.status} | {match.venue_city || '場地待定'}</p>
|
||
</Link>
|
||
)) : (
|
||
<p className="text-sm text-[#7a5b46]">{errors.matches ?? '尚無可顯示賽程。'}</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-5">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]">新聞脈搏</h3>
|
||
<Link href="/news" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]">新聞頁</Link>
|
||
</div>
|
||
<div className="mt-4 space-y-3">
|
||
{news.slice(0, 5).map((item) => (
|
||
<a key={item.url} href={item.url} target="_blank" rel="noreferrer" className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
|
||
<p className="text-sm font-semibold leading-5 text-[#3f2f25]">{item.title}</p>
|
||
<p className="mt-2 text-xs text-[#7a5b46]">{item.source} | {item.publishedAt ? formatToTaipeiTime(item.publishedAt, 'MM/dd HH:mm') : '時間待定'}</p>
|
||
</a>
|
||
))}
|
||
{!news.length ? <p className="text-sm text-[#7a5b46]">{errors.news ?? '尚無新聞資料。'}</p> : null}
|
||
</div>
|
||
</section>
|
||
</aside>
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-6">
|
||
<h2 className="dot-matrix text-xl text-[#7d2a15]">核心頁面入口</h2>
|
||
<p className="mt-2 text-sm text-[#7a5b46]">首頁不再把所有工具一次攤開;這裡只保留主流程,其他進階工具放在上方工具箱。</p>
|
||
<div className="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||
{moduleLinks.map((item) => (
|
||
<Link key={item.href} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822] hover:bg-[#fff0e2]">
|
||
<p className="font-bold text-[#3f2f25]">{item.label}</p>
|
||
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.desc}</p>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|