'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 pickMatchKey(item: DailyCardItem): string { return String(item.match_id ?? item.match_label).trim().toLowerCase(); } function pickMarketGroup(item: DailyCardItem): string { if (item.recommendation === 'SAFE_PARLAY') return '跨場串關'; if (item.recommendation === 'SGP_LOTTERY') return '同場串關'; if (item.recommendation === 'HIGH_RISK_SINGLE') return '小注高賠'; return item.market_type || '單關'; } function diversifyPicks(items: DailyCardItem[], limit: number): DailyCardItem[] { const selected: DailyCardItem[] = []; const seen = new Set(); const matchCounts = new Map(); const matchMarketSeen = new Set(); const tryAdd = (item: DailyCardItem, maxPerMatch: number, enforceMarketSpread: boolean) => { if (selected.length >= limit) return; const key = pickKey(item); if (seen.has(key)) return; const matchKey = pickMatchKey(item); const marketKey = `${matchKey}|${pickMarketGroup(item)}`; if ((matchCounts.get(matchKey) ?? 0) >= maxPerMatch) return; if (enforceMarketSpread && matchMarketSeen.has(marketKey)) return; selected.push(item); seen.add(key); matchCounts.set(matchKey, (matchCounts.get(matchKey) ?? 0) + 1); matchMarketSeen.add(marketKey); }; items.forEach((item) => tryAdd(item, 2, true)); items.forEach((item) => tryAdd(item, 3, false)); return selected.slice(0, limit); } function shortBetInstruction(item: DailyCardItem): string { if (item.recommendation === 'SAFE_PARLAY') return '到投注平台選「串關」,把每一腿分開加入;任一腿賠率不達標就整組跳過。'; if (item.recommendation === 'SGP_LOTTERY') 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(null); const [tomorrowData, setTomorrowData] = useState(null); const [dailyCards, setDailyCards] = useState>({}); const [calendarDates, setCalendarDates] = useState([]); const [selectedRecommendationDate, setSelectedRecommendationDate] = useState(''); const [matches, setMatches] = useState([]); const [news, setNews] = useState([]); const [sourceHealth, setSourceHealth] = useState(null); const [watchlist, setWatchlist] = useState([]); const [feedbackMessage, setFeedbackMessage] = useState(''); const [loading, setLoading] = useState(true); const [errors, setErrors] = useState({}); 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 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 = {}; 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(); 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(() => { 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 diversifyPicks([...balanced, ...remaining], 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 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}。最低賠率 ${item.target_odds.toFixed(2)},參考上限 ${formatTwd(stakeAmountTwd(item))}。`); 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 (

首頁第一屏 / {dateWindow.today} - {dateWindow.tomorrow}

下一批可檢查投注候選

一打開首頁先看這裡:系統會優先顯示今天與明天,再依日期列出後續可分析賽事。清單會自動分散場次與玩法,避免同一場比賽洗版;若資料不足,卡片會標成「預掛觀察」,只代表先盯賠率門檻,不是叫你立刻下注。

目前主顯示日期:{activeRecommendationDate} 。當日賽事 {selectedMatchCount} 場,推薦 {recommendationItems.length} 組。

可檢查 {liveRecommendationCount} 組 預掛監控 {watchOnlyRecommendationCount} 組 60 秒刷新 首頁最多每場優先顯示 2 組
{dateSummaries.map((item) => { const pinnedLabel = item.date === dateWindow.today ? '今天' : item.date === dateWindow.tomorrow ? '明天' : ''; return ( ); })}
{categorySummary.map((item) => ( {item.label} {item.value} 組 ))}
{feedbackMessage ? (

{feedbackMessage}

) : null}

我的賠率監控清單

{watchlist.length ? `已追蹤 ${watchlist.length} 組候選` : '還沒有加入候選'}

加入後不是立刻下注,而是集中檢查來源、最低賠率、玩法與注碼上限。台灣盤參考價與模型推算門檻會分開標示。

打開完整檢查清單
{watchlist.length ? (
{watchlist.slice(0, 4).map((item) => { const conditional = isConditionalPick(item) || !item.has_market_odds; return (
{conditional ? '先監控' : '可檢查'} {item.odds_source_label ? ( {item.odds_source_label} ) : null}

{item.match_label}

{item.market_type}|{item.selection}

門檻
{item.target_odds.toFixed(2)}

信心
{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : item.win_prob.toFixed(1)}

上限
{formatTwd(stakeAmountTwd(item))}

); })}
) : (

看到候選卡片時,先按「加入賠率監控」或「加入下注前檢查」。清單會保留在此瀏覽器中,方便你逐筆核對,不用重新找。

)}
{loading ? (

正在同步日期賽事與多玩法推薦...

) : topPicks.length ? (
{topPicks.slice(0, 9).map((item) => { const conditional = isConditionalPick(item) || !item.has_market_odds; const inList = watchlistKeys.has(pickKey(item)); return (
{recommendationText(item)} {conditional ? '預掛觀察' : '下注前檢查'} {typeof item.confidence_score === 'number' ? 信心分數 {item.confidence_score.toFixed(1)} : null} {item.odds_source_label ? {item.odds_source_label} : null}

{item.match_label}

台北日期:{activeDateLabel}

{item.market_type}|{item.selection}

同一場比賽可能有不同玩法;不同玩法的賠率不能互相比大小,只能和該玩法自己的最低門檻比較。

{shortBetInstruction(item)}

最低可接受賠率:{item.target_odds.toFixed(2)}

模型勝率:{item.win_prob.toFixed(2)}%

參考上限:{formatTwd(stakeAmountTwd(item))}

{nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。

完整分析
); })}
) : (

目前沒有通過模型勝率、最低賠率、資料新鮮度與倉位上限的候選。專業推薦不是硬湊數量,沒有優勢就保留資金,等下一輪資料刷新。

)}

台北即時投注研究台 / {activeDateState} / {activeRecommendationDate}

世界盃下注推薦主控台

首頁先顯示可研究的投注候選,再判斷能不能進場。{activeDateSummaryText} 資料不足時只開監控,不用漂亮空殼假裝高勝率。

{gate.label} {loadedAt || '同步中'}

{gate.title}

{gate.detail}

正式盤口:{sourceHealth?.odds_coverage_status === 'full_market' ? '已具備' : '不足'} 逾時賽果:{sourceHealth?.stale_unsettled_matches ?? 0} 場 目前日期:{activeDateLabel} 監控清單:{watchlist.length} 筆
{gate.primaryAction} 檢查盤口覆蓋 推薦就緒度

專業檢查順序

60 秒刷新
{[ ['1', '先看狀態', '若是只監控或資料延遲,就不要直接下注。'], ['2', '確認玩法', '單關、串關、同場串關要分開看,不混成同一種推薦。'], ['3', '比對賠率', '平台賠率低於最低門檻就跳過,不能硬追。'], ['4', '控制注碼', '只用卡片建議單位,尤其高賠與同場串關要小注。'], ].map(([step, title, detail]) => (
{step}

{title}

{detail}

))}
{[ { 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) => (

{item.helper}

{item.value}

{item.label}

))}

依日期優先檢查 / {activeDateState}

{activeDateLabel} 多玩法候選清單

{activeDateSummaryText} 詳細下注方式與白話分析請進每日作戰室逐張檢查。

看完整作戰室
{feedbackMessage ? (

{feedbackMessage}

) : null} {loading ? (

同步市場即時資料中...

) : topPicks.length ? (
{topPicks.map((item) => { const conditional = isConditionalPick(item) || !item.has_market_odds; const inList = watchlistKeys.has(pickKey(item)); return (
{recommendationText(item)} {conditional ? '先監控' : '可檢查'} {typeof item.confidence_score === 'number' ? 信心分數 {item.confidence_score.toFixed(1)} : null} {activeDateLabel} {item.confidence_band ? {item.confidence_band} : null}

{item.match_label}

{item.market_type} | {item.selection}

{shortBetInstruction(item)}

模型勝率 {item.win_prob.toFixed(2)}%

最低賠率 {item.target_odds.toFixed(2)}

參考上限 {formatTwd(stakeAmountTwd(item))}

{nextStepText(item)} 平台賠率必須大於或等於 {item.target_odds.toFixed(2)};低於這個數字就直接跳過。信心分數是綜合評分,不是命中率。

看完整分析
); })}
) : (

目前沒有通過勝率、期望值、資料新鮮度與注碼上限的候選。沒有優勢就不硬推單,這才是專業紀律。

{errors.daily ?? selectedCard?.summary ?? '等待下一次資料刷新。'}

)}

核心頁面入口

首頁不再把所有工具一次攤開;這裡只保留主流程,其他進階工具放在上方工具箱。

{moduleLinks.map((item) => (

{item.label}

{item.desc}

))}
); }